The do notation module provides Haskell-inspired monadic composition using JavaScript/TypeScript generator functions. This allows you to write async workflows in a more imperative, readable style while maintaining functional purity and composability.
Traditional monadic composition can become deeply nested and hard to follow:
// Traditional approach - hard to read
const workflow = getUserById(userId)
.then(user => getProfileById(user.profileId)
.then(profile => getSettingsById(user.settingsId)
.then(settings => ({ user, profile, settings }))
)
);With do notation, the same logic becomes linear and readable:
// With do notation - much cleaner
const workflow = doTask(function* (userId: string) {
const user = yield getUserById(userId);
const profile = yield getProfileById(user.profileId);
const settings = yield getSettingsById(user.settingsId);
return { user, profile, settings };
});Use doTask to create a Task that executes generator-based monadic composition:
import { doTask, pure } from 'effectively';
const myWorkflow = doTask(function* (input: string) {
// Yield monadic values to unwrap them
const result1 = yield someAsyncTask(input);
const result2 = yield anotherTask(result1);
// Return the final result
return result2;
});The do notation can unwrap several types of monadic values:
const workflow = doTask(function* () {
// Tasks from the effectively library
const user = yield getUser('123');
// Regular Promises
const data = yield fetch('/api/data').then(r => r.json());
// Result types (neverthrow)
const parsed = yield parseJson(jsonString); // Result<Data, Error>
// Plain values (automatically lifted)
const plain = yield pure(42);
return { user, data, parsed, plain };
});For better type inference and context handling, create context-specific do notation:
interface MyContext extends BaseContext {
db: Database;
logger: Logger;
config: Config;
}
const { doTask: myDoTask, doBlock } = createDoNotation<MyContext>();
// Now with proper context typing
const workflow = myDoTask(function* (userId: string) {
const user = yield getUser(userId); // Uses MyContext
const posts = yield getUserPosts(user.id);
return { user, posts };
});One of the most powerful features of do notation is the ability to compose generators using yield*. This enables you to break down complex workflows into reusable sub-generators:
// Reusable generator for fetching core user data
function* fetchUserCore(userId: string) {
const user = yield getUser(userId);
const profile = yield getProfile(user.id);
return { user, profile };
}
// Reusable generator for fetching user preferences
function* fetchUserPreferences(userId: string) {
const settings = yield getSettings(userId);
const permissions = yield getPermissions(userId);
return { settings, permissions };
}
// Compose them into a larger workflow
const fetchCompleteUserData = doTask(function* (userId: string) {
const coreData = yield* fetchUserCore(userId); // Delegate to sub-generator
const preferences = yield* fetchUserPreferences(userId); // Another delegation
const analytics = yield getAnalytics(userId); // Direct yield
return {
...coreData,
...preferences,
analytics
};
});- Reusability: Sub-generators can be used across multiple workflows
- Testability: Each sub-generator can be tested independently
- Modularity: Complex workflows can be broken into logical chunks
- Type Safety: TypeScript properly infers types through composition
Retry Pattern with yield*
function* withRetryPattern<T>(operation: () => MonadicValue<any, T>, maxAttempts = 3) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return yield operation();
} catch (error) {
if (attempt === maxAttempts) throw error;
// Could add exponential backoff here
yield pure(undefined); // Small delay placeholder
}
}
}
const resilientFetch = doTask(function* (url: string) {
return yield* withRetryPattern(() => fetchData(url));
});Authentication Wrapper
function* withAuth<T>(operation: () => MonadicValue<any, T>) {
const token = yield getCurrentToken();
if (!token || isExpired(token)) {
yield refreshToken();
}
return yield operation();
}
const secureApiCall = doTask(function* (endpoint: string) {
return yield* withAuth(() => apiCall(endpoint));
});Errors in yielded monadic values are automatically propagated and can be caught:
const workflow = doTask(function* (userId: string) {
try {
const user = yield getUser(userId);
const profile = yield getProfile(user.id);
return { user, profile };
} catch (error) {
// Handle errors from any yielded operation
console.error('Workflow failed:', error);
throw error;
}
});Use doWhen and doUnless for conditional monadic execution:
import { doWhen, doUnless } from 'effectively';
const workflow = doTask(function* (userId: string) {
const user = yield getUser(userId);
// Conditional execution
const adminData = yield doWhen(
user.isAdmin,
() => getAdminData(userId),
() => pure(null)
);
// Execute only if condition is false
yield doUnless(user.isActive, () => activateUser(userId));
return { user, adminData };
});Use sequence to execute multiple monadic operations and collect results:
const workflow = doTask(function* (userIds: string[]) {
// Execute getUser for each ID sequentially
const users = yield sequence(userIds.map(id => getUser(id)));
return users;
});Use forEach to iterate over arrays with monadic operations:
const workflow = doTask(function* (userIds: string[]) {
yield forEach(userIds, function* (userId) {
const user = yield getUser(userId);
yield logUserActivity(user);
yield updateUserStatus(user.id, 'processed');
});
});Here's a comprehensive example showing various features:
import {
doTask,
createDoNotation,
doWhen,
sequence,
pure
} from 'effectively';
interface AppContext extends BaseContext {
db: Database;
cache: Cache;
logger: Logger;
}
const { doTask: appDoTask } = createDoNotation<AppContext>();
// Define some tasks
const getUser = defineTask(async (id: string) => {
const ctx = getContext<AppContext>();
return ctx.db.users.findById(id);
});
const getCachedProfile = defineTask(async (userId: string) => {
const ctx = getContext<AppContext>();
const cached = await ctx.cache.get(`profile:${userId}`);
if (cached) return cached;
const profile = await ctx.db.profiles.findByUserId(userId);
await ctx.cache.set(`profile:${userId}`, profile);
return profile;
});
const updateUserLastSeen = defineTask(async (userId: string) => {
const ctx = getContext<AppContext>();
return ctx.db.users.update(userId, { lastSeen: new Date() });
});
// Main workflow using do notation
const getUserWithProfile = appDoTask(function* (userId: string) {
const ctx = getContext<AppContext>();
ctx.logger.info('Starting user lookup', { userId });
try {
// Get user data
const user = yield getUser(userId);
if (!user) {
throw new Error(`User ${userId} not found`);
}
// Conditionally get profile based on user settings
const profile = yield doWhen(
user.profileEnabled,
() => getCachedProfile(userId),
() => pure(null)
);
// Update last seen for active users
yield doWhen(
user.isActive,
() => updateUserLastSeen(userId),
() => pure(undefined)
);
// If user has friends, get their basic info
const friends = user.friendIds.length > 0
? yield sequence(user.friendIds.slice(0, 5).map(id => getUser(id)))
: [];
ctx.logger.info('User lookup completed', { userId, hasFriends: friends.length > 0 });
return {
user,
profile,
friends: friends.map(f => ({ id: f.id, name: f.name }))
};
} catch (error) {
ctx.logger.error('User lookup failed', { userId, error });
throw error;
}
});
// Usage
async function main() {
const { run } = createContext<AppContext>({
db: new Database(),
cache: new Cache(),
logger: console
});
const result = await run(getUserWithProfile, 'user123');
console.log(result);
}- Readability: Linear, imperative-style code that's easy to follow
- Error Handling: Automatic error propagation with standard try/catch
- Type Safety: Full TypeScript support with proper type inference
- Composability: Works seamlessly with existing Task-based workflows
- Performance: No additional overhead - compiles to efficient async/await code
- Flexibility: Supports any monadic type (Tasks, Promises, Results, etc.)
- Use descriptive generator function names for better debugging
- Keep do blocks focused - break large workflows into smaller, composable pieces
- Handle errors appropriately - use try/catch for expected error conditions
- Leverage context-specific do notation for better type safety
- Combine with other patterns - do notation works well with circuit breakers, retries, etc.
| Pattern | Pros | Cons |
|---|---|---|
| Promise chains | Native JS/TS | Deeply nested, hard to read |
| Async/await | Familiar syntax | Doesn't work well with monadic types |
| Manual composition | Full control | Verbose, error-prone |
| Do notation | Readable, composable, type-safe | Requires understanding generators |
The do notation provides the best of both worlds: the readability of imperative code with the power and safety of functional composition.