Details
The 16-Line Pattern That Eliminates Dependency Passing
Learn how a tiny runtime using JavaScript generators can transform how you structure applications
Building Intuition: The Restaurant Analogy
Imagine two restaurants:
Restaurant A (Dependency Passing)
- Every tool is passed down the chain: Manager → Head Chef → Line Cook → Prep Cook.
- If the Prep Cook needs a salt shaker, the Manager, Head Chef, and Line Cook all have to handle the salt shaker just to pass it along.
- Adding a new station with new tools requires updating everyone's passing duties.
- Each person is burdened with handling supplies they don't need, just to pass them along.
Restaurant B (Our Pattern)
- The kitchen has a Quartermaster who manages all supplies.
- When a cook is hired, they simply state their role: "I'm the new Prep Cook."
- The Quartermaster gives them exactly what a Prep Cook needs: a knife, a cutting board, and a salt shaker.
- Cooks focus on cooking, not on the supply chain. New tools for a role? Just tell the Quartermaster.
This is the shift we're making in code.
The Problem: Dependency Passing Hell
Let's see this in code you've probably written:
// ❌ The problem: Every function needs ALL dependencies passed through
async function saveUserHandler(req, res, db, logger, cache, emailService, validator) {
const { userId, data } = req.body;
// This function only uses logger, but needs all deps to pass them down
logger.info(`Request to save user ${userId}`);
const result = await updateUserProfile(userId, data, db, logger, cache, emailService, validator);
res.json(result);
}
async function updateUserProfile(userId, data, db, logger, cache, emailService, validator) {
// This function doesn't use emailService, but must pass it along
logger.info(`Updating profile for ${userId}`);
const user = await loadAndValidateUser(userId, db, logger, cache, validator);
const updatedUser = await saveUser(user, data, db, logger, emailService);
return updatedUser;
}
async function loadAndValidateUser(userId, db, logger, cache, validator) {
// More dependency passing...
const cached = await cache.get(`user:${userId}`);
if (cached) {
logger.info('Cache hit');
return cached;
}
const user = await db.findUser(userId);
if (!validator.isValidUser(user)) {
throw new Error('Invalid user');
}
await cache.set(`user:${userId}`, user);
return user;
}
async function saveUser(user, data, db, logger, emailService) {
// Finally using emailService, 3 levels deep!
const updated = await db.updateUser(user.id, data);
await emailService.sendUpdateEmail(user.email, updated);
logger.info('User saved and email sent');
return updated;
}
What is Dependency Passing?
This pattern of passing dependencies through multiple function layers is known as "dependency passing" or "parameter threading". In React, it's called "prop drilling", but the problem exists in all JavaScript code - functions receiving dependencies they don't use, just to pass them to other functions.
Visualizing the Pain:
Dependency Passing Visualization:
saveHandler ━━━━━━━━━━━━━━━━━━━━━━━━━━┓
↓ (db, logger, cache, email, ...) ┃
updateProfile ━━━━━━━━━━━━━━━━━━━━━━━━┫
↓ (db, logger, cache, email, ...) ┃ Height = Pain
loadUser ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
↓ (db, logger, cache, email, ...) ┃
validateUser ━━━━━━━━━━━━━━━━━━━━━━━━━┫
↓ (db, logger, cache, email, ...) ┃
saveUser ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
(finally uses email!)
The Mind-Shift: From PUSH to PULL
Here's the key insight that changes everything:
// The traditional way: "How do I get dependencies TO my function?"
// Structure dictates dependencies - PUSH model
function doWork(logger, db, cache, validator, emailer, metric, queue) {
// Must accept ALL dependencies, even if we only use logger
logger.info('Working...');
}
// The new way: "How can my function GET dependencies when needed?"
// Logic dictates dependencies - PULL model
function* doWork() {
// Only get what you need, when you need it!
const logger = yield getLogger();
logger.info('Working...');
if (someCondition) {
const db = yield getDatabase(); // Pull conditionally
// ...
}
// Never needed cache, validator, emailer, metric, or queue? Never pulled them!
}
The Pattern Visualized:
Generator Pattern:
saveHandler ●
│
updateProfile ● ←──── runtime ←──── context
│ ↑ (all deps)
loadUser ● ←─────────┘
│
validateUser ● ←────────┘
│
saveUser ● ←─────────┘
"Why Not Just..." - Addressing the Alternatives
You might be thinking of alternatives. Let's address them:
Why not just use a global object?
// Global approach
global.db.findUser(123);
// Problems:
// ❌ Can't test without modifying globals
// ❌ No dependency tracking - what does this function use?
// ❌ Breaks with multiple contexts (e.g., multi-tenant)
// ❌ Hidden coupling - dependencies aren't explicit
Why not use classes and constructor injection?
class UserService {
constructor(db, logger, cache, validator, emailer) {
// Back to dependency passing in the constructor!
this.db = db;
this.logger = logger;
// ...
}
}
// ❌ Still need to pass all dependencies
// ❌ Instances become stateful
// ❌ Hard to compose operations
Why not use React Context or similar?
const UserContext = React.createContext();
// ❌ Only works in React
// ❌ Still requires provider setup
// ❌ Can't compose workflows
// ❌ Limited to component tree
Our solution achieves the same goals with just 16 lines of vanilla JavaScript.
Understanding Generators: The Pause/Resume Mechanism
Before we build our solution, let's understand generators:
function* simpleExample() {
console.log('1. Generator starts');
const value = yield 'Hello'; // PAUSE here, send 'Hello' out
console.log('2. Generator resumes with:', value);
const value2 = yield 'World'; // PAUSE again, send 'World' out
console.log('3. Generator resumes with:', value2);
return 'Done';
}
// Let's manually control the generator
const gen = simpleExample();
console.log('Starting...');
const result1 = gen.next(); // Run until first yield
console.log('Got:', result1); // { value: 'Hello', done: false }
const result2 = gen.next('Hi!'); // Resume with 'Hi!'
console.log('Got:', result2); // { value: 'World', done: false }
const result3 = gen.next('Bye!'); // Resume with 'Bye!'
console.log('Got:', result3); // { value: 'Done', done: true }
Output:
Starting...
1. Generator starts
Got: { value: 'Hello', done: false }
2. Generator resumes with: Hi!
Got: { value: 'World', done: false }
3. Generator resumes with: Bye!
Got: { value: 'Done', done: true }
🤔 Check Your Understanding #1
Before continuing, make sure you can answer:
- Why do generators allow us to "pause" execution?
- What happens when we call
yield?
- How do we send values back into a generator?
Answers
- Generators pause at each
yield, returning control to the caller
yield pauses the generator and sends out a value
- We send values back using
gen.next(value) - the value becomes the result of the yield
Building the Pattern Step by Step
Step 1: The Operation Description Pattern
Let's build up to our pattern progressively:
// Building up to the pattern:
// Step 1: We need to call this operation
context.db.findUser(123)
// Step 2: But we don't have context yet! Wrap in a function
() => context.db.findUser(123)
// Step 3: But context isn't in scope! Accept as parameter
(context) => context.db.findUser(123)
// Step 4: But we need to capture the ID! Wrap again
(id) => (context) => context.db.findUser(id)
// Final pattern: Parameter capture + Context injection
const getUser = (id) => (context) => context.db.findUser(id);
This creates a description of an operation without executing it:
// Operation descriptions - these DON'T execute anything
const getUser = (id) => (context) => context.db.findUser(id);
const log = (msg) => (context) => context.logger.info(msg);
const validate = (data) => (context) => context.validator.validate(data);
Step 2: Building the Runtime - Basic Version
Let's build our runtime step by step to understand how it works:
// Version 1: Manual execution (no loop)
async function simpleRuntime(generatorFn, context) {
const gen = generatorFn();
// Start the generator
let step1 = await gen.next();
console.log('Generator yielded:', step1.value);
// Execute what it asked for
let result1 = await step1.value(context);
// Give it back
let step2 = await gen.next(result1);
console.log('Generator yielded:', step2.value);
// This gets repetitive... we need a loop!
}
// Version 2: Add the loop
async function runtimeWithLoop(generatorFn, context) {
const gen = generatorFn();
let result = await gen.next();
while (!result.done) {
// Execute the operation
const value = await result.value(context);
// Continue the generator
result = await gen.next(value);
}
return result.value;
}
// Version 3: Add error handling (our final version)
function runtime(generatorFunction) {
// Return executor that will inject dependencies
return async function execute(context) {
// Create new generator instance
const generator = generatorFunction();
// Run generator until first yield
let result = await generator.next();
// Process each yield until done
while (!result.done) {
// Get the operation function that was yielded
const operation = result.value;
try {
// Execute operation with context, get result
const operationResult = await operation(context);
// Resume generator with result
result = await generator.next(operationResult);
} catch (error) {
// Forward errors into generator
result = await generator.throw(error);
}
}
// Return final value
return result.value;
};
}
🤔 Check Your Understanding #2
- Why do we need a loop in our runtime?
- What does
operation(context) do?
- Why do we have a try/catch block?
Answers
- Generators can yield multiple times, we need to handle each yield
- It executes the operation description with the actual context
- To forward errors from operations back to the generator
## Complete Working Example
Let's see it all together:
// ============================================
// 1. THE RUNTIME (copy this to use the pattern)
// ============================================
function runtime(generatorFunction) {
return async function execute(context) {
const generator = generatorFunction();
let result = await generator.next();
while (!result.done) {
const operation = result.value;
try {
const operationResult = await operation(context);
result = await generator.next(operationResult);
} catch (error) {
result = await generator.throw(error);
}
}
return result.value;
};
}
// ============================================
// 2. OPERATION DEFINITIONS
// ============================================
const log = (message) =>
(context) => context.logger.info(message);
const getUser = (id) =>
(context) => context.db.findUser(id);
const updateUser = (id, data) =>
(context) => context.db.updateUser(id, data);
const sendEmail = (to, subject, body) =>
(context) => context.email.send(to, subject, body);
const cacheGet = (key) =>
(context) => context.cache.get(key);
const cacheSet = (key, value) =>
(context) => context.cache.set(key, value);
// ============================================
// 3. BUSINESS LOGIC (using generators)
// ============================================
async function* updateUserProfile(userId, newData) {
yield log(`Starting update for user ${userId}`);
// Check cache first
const cacheKey = `user:${userId}`;
let user = yield cacheGet(cacheKey);
if (!user) {
yield log('Cache miss - loading from database');
user = yield getUser(userId);
if (!user) {
throw new Error('User not found');
}
// Cache for next time
yield cacheSet(cacheKey, user);
} else {
yield log('Cache hit');
}
// Update the user
const updated = { ...user, ...newData, updatedAt: new Date() };
yield updateUser(userId, updated);
yield cacheSet(cacheKey, updated);
// Send notification
yield sendEmail(
user.email,
'Profile Updated',
`Hi ${updated.name}, your profile was updated.`
);
yield log('Update completed successfully');
return updated;
}
// ============================================
// 4. USAGE
// ============================================
async function main() {
// Create the context with real implementations
const context = {
logger: {
info: (msg) => console.log(`[LOG] ${msg}`)
},
db: {
findUser: async (id) => {
console.log(`[DB] Finding user ${id}`);
return { id, name: 'John Doe', email: 'john@example.com' };
},
updateUser: async (id, data) => {
console.log(`[DB] Updating user ${id}`);
return data;
}
},
email: {
send: async (to, subject, body) => {
console.log(`[EMAIL] To: ${to}, Subject: ${subject}`);
}
},
cache: (() => {
const storage = new Map();
return {
get: async (key) => {
console.log(`[CACHE] Getting ${key}`);
return storage.get(key);
},
set: async (key, value) => {
console.log(`[CACHE] Setting ${key}`);
storage.set(key, value);
}
};
})()
};
// Create executable function
const runUpdate = runtime(() => updateUserProfile(123, { name: 'Jane Doe' }));
// Execute with our context
const result = await runUpdate(context);
console.log('\n[RESULT]', result);
}
main();
Side-by-Side Comparison
Let's see the difference clearly:
// ❌ Traditional: 7 parameters, dependency passing
async function createUserTraditional(
name, email, db, logger, validator, emailer, cache
) {
logger.info('Creating user');
if (!validator.isValidEmail(email)) {
logger.error('Invalid email');
throw new Error('Invalid email');
}
const user = await db.createUser({ name, email });
await cache.set(`user:${user.id}`, user);
await emailer.sendWelcome(email);
logger.info('User created');
return user;
}
// ✅ Generator: 2 parameters, clear intent
async function* createUserGenerator(name, email) {
yield log('Creating user');
const isValid = yield validateEmail(email);
if (!isValid) {
yield logError('Invalid email');
throw new Error('Invalid email');
}
const user = yield createUser({ name, email });
yield cacheUser(user);
yield sendWelcomeEmail(email);
yield log('User created');
return user;
}
// Result: fewer parameters, clearer intent
Error Handling: Complete Understanding
Let's deeply understand how errors flow through the system:
// Example 1: Generator handles the error
async function* safeWorkflow() {
try {
yield log('Attempting risky operation');
const user = yield getUser(999); // This might throw
return user;
} catch (error) {
yield log(`Error caught: ${error.message}`);
return null; // Generator handles it gracefully
}
}
// Example 2: Generator doesn't catch - error propagates
async function* unsafeWorkflow() {
const user = yield getUser(999); // If this throws...
return user; // Never reached
}
// Error flow visualization:
// 1. getUser(999) throws in context.db.findUser
// 2. Runtime catches in its try/catch
// 3. Runtime calls generator.throw(error)
// 4. If generator has try/catch: error is caught there
// 5. If not: error propagates to runtime caller
// Example 3: Cleanup on error
async function* workflowWithCleanup() {
yield log('Starting transaction');
const transaction = yield beginTransaction();
try {
const user = yield createUser({ name: 'Test' });
const profile = yield createProfile(user.id);
yield commitTransaction(transaction);
return { user, profile };
} catch (error) {
yield log('Error occurred, rolling back');
yield rollbackTransaction(transaction);
throw error; // Re-throw after cleanup
}
}
Exercise: Convert This Code
Here's a real function that suffers from dependency passing. Try converting it yourself:
// ❌ Traditional approach - lots of dependencies
async function processOrder(
customerId,
items,
db,
logger,
inventory,
payment,
email,
metrics
) {
logger.info(`Creating order for customer ${customerId}`);
metrics.increment('orders.started');
const customer = await db.findCustomer(customerId);
if (!customer) {
logger.error('Customer not found');
throw new Error('Customer not found');
}
for (const item of items) {
const available = await inventory.check(item.productId, item.quantity);
if (!available) {
logger.error(`Product ${item.productId} out of stock`);
throw new Error('Out of stock');
}
}
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const paymentResult = await payment.charge(customer.paymentMethodId, total);
const order = await db.createOrder({
customerId,
items,
total,
paymentId: paymentResult.id
});
await email.sendOrderConfirmation(customer.email, order);
metrics.increment('orders.completed');
logger.info(`Order ${order.id} created successfully`);
return order;
}
✅ Solution: Using the generator pattern
// First, create the operations
const log = (msg) => (ctx) => ctx.logger.info(msg);
const logError = (msg) => (ctx) => ctx.logger.error(msg);
const findCustomer = (id) => (ctx) => ctx.db.findCustomer(id);
const checkInventory = (productId, quantity) =>
(ctx) => ctx.inventory.check(productId, quantity);
const chargePayment = (methodId, amount) =>
(ctx) => ctx.payment.charge(methodId, amount);
const createOrder = (data) => (ctx) => ctx.db.createOrder(data);
const sendOrderEmail = (email, order) =>
(ctx) => ctx.email.sendOrderConfirmation(email, order);
const incrementMetric = (name) => (ctx) => ctx.metrics.increment(name);
// Then, the generator workflow
async function* processOrder(customerId, items) {
yield log(`Creating order for customer ${customerId}`);
yield incrementMetric('orders.started');
const customer = yield findCustomer(customerId);
if (!customer) {
yield logError('Customer not found');
throw new Error('Customer not found');
}
for (const item of items) {
const available = yield checkInventory(item.productId, item.quantity);
if (!available) {
yield logError(`Product ${item.productId} out of stock`);
throw new Error('Out of stock');
}
}
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const paymentResult = yield chargePayment(customer.paymentMethodId, total);
const order = yield createOrder({
customerId,
items,
total,
paymentId: paymentResult.id
});
yield sendOrderEmail(customer.email, order);
yield incrementMetric('orders.completed');
yield log(`Order ${order.id} created successfully`);
return order;
}
Notice how the generator version:
- Takes only 2 parameters instead of 8
- Doesn't need to know about dependencies
- Is easier to read and follow
- Can be tested with simple mocks
Making Sense of It: How This Connects to What You Know
This pattern isn't as strange as it looks. It’s just a clever mix of ideas you've probably already seen.
Like Express Middleware
Both patterns pass control. But yield keeps your logic clean by avoiding the need to pass a messy req object everywhere.
// Express middleware passes control
app.use((req, res, next) => {
console.log('Logging');
next(); // Pass control to next middleware
});
// Our pattern is similar
async function* workflow() {
yield log('Logging'); // Pass control to runtime
const user = yield getUser(); // Pass control to runtime
return user;
}
Like Async/Await
It's the same "pause and resume" mechanic. await pauses for time; yield pauses for a dependency.
// async/await: pause for TIME (waiting for I/O)
async function fetchUser() {
const user = await fetch('/api/user'); // Pause for network
return user;
}
// generators: pause for DEPENDENCIES (waiting for context)
async function* fetchUser() {
const user = yield getUser(); // Pause for dependency
return user;
}
Like a DI Container
You get the main benefit of a big Dependency Injection framework—decoupled code—without any of the complexity.
// Traditional DI container (like Angular)
@Injectable()
class UserService {
constructor(private db: Database) {}
}
// Our pattern - same concept, no framework
async function* userService() {
const db = yield getDatabase();
}
It avoids Node-only features like AsyncLocalStorage, it's explicit instead of magical, and it’s easy to type-check and debug.
Real-World Case Study: Redux-Saga
Redux-Saga, used by millions, implements this exact pattern:
// Redux-Saga code
function* watchUserLogin() {
while (true) {
const action = yield take('USER_LOGIN'); // Pause for action
const user = yield call(api.login, action.payload); // Pause for API
yield put({ type: 'LOGIN_SUCCESS', user }); // Pause for dispatch
}
}
// Our pattern - identical concept
async function* watchUserLogin() {
while (true) {
const action = yield waitForAction('USER_LOGIN');
const user = yield callApi('/login', action.payload);
yield dispatch({ type: 'LOGIN_SUCCESS', user });
}
}
The pattern you're learning powers production systems handling millions of requests.
Gradual Migration Guide
You don't need to rewrite everything at once. Here's how to migrate gradually:
Step 1: Start with Leaf Functions
// Before: Leaf function with dependencies
async function sendNotification(userId, message, db, emailService) {
const user = await db.getUser(userId);
await emailService.send(user.email, message);
}
// After: Convert to generator
async function* sendNotification(userId, message) {
const user = yield getUser(userId);
yield sendEmail(user.email, message);
}
Step 2: Create a Hybrid Wrapper
// Wrapper that works both ways
function sendNotification(userIdOrContext, message, db, emailService) {
// New way: single context argument
if (arguments.length === 1 && typeof userIdOrContext === 'object') {
const context = userId;
return runtime(function* () {
const user = yield getUser(context.userId);
yield sendEmail(user.email, context.message);
})(context);
}
// Old way: multiple dependencies
return (async () => {
const user = await db.getUser(userId);
await emailService.send(user.email, message);
})();
}
Step 3: Update Callers One by One
// Old caller
await sendNotification(123, 'Hello', db, emailService);
// New caller
await sendNotification({
userId: 123,
message: 'Hello',
// context goes here
});
Step 4: Remove Old Code Path
Once all callers are updated, remove the old code path and fully embrace the pattern.
Why This Works: Inversion of Control
This pattern implements a fundamental principle called "Inversion of Control" (IoC):
Traditional Control Flow:
Your Code → Calls → Dependencies
(You control when/how dependencies are called)
Inverted Control Flow:
Your Code → Describes → What It Needs
Runtime → Provides → Dependencies
(Runtime controls when/how dependencies are provided)
This is the same principle behind:
- Dependency Injection containers (Spring, Angular)
- React hooks (
useContext, useState)
- Middleware systems (Express, Koa)
- Effect systems (Effect-TS, ZIO)
But implemented in just 16 lines of vanilla JavaScript!
Testing: Now It's Trivial
Testing becomes incredibly simple:
test('updateUserProfile updates user correctly', async () => {
// Create test context - only mock what you use!
const mockUser = { id: 1, name: 'Old Name' };
const testContext = {
logger: { info: jest.fn() },
db: {
findUser: jest.fn().mockResolvedValue(mockUser),
updateUser: jest.fn().mockResolvedValue({ ...mockUser, name: 'New Name' })
},
cache: {
get: jest.fn().mockResolvedValue(null),
set: jest.fn()
},
email: {
send: jest.fn()
}
};
// Run workflow
const runUpdate = runtime(() => updateUserProfile(1, { name: 'New Name' }));
const result = await runUpdate(testContext);
// Assert
expect(testContext.db.findUser).toHaveBeenCalledWith(1);
expect(result.name).toBe('New Name');
expect(testContext.email.send).toHaveBeenCalled();
});
TypeScript
The pattern works beautifully with TypeScript, but getting proper type inference requires understanding one quirk of generators. Let's build up to a fully type-safe solution, step by step.
Step 1: The Problem We're Solving
Without proper typing, TypeScript can't help us catch errors:
// ❌ PROBLEM: No type information
async function* updateUserWorkflow(userId) {
const user = yield getUser(userId);
// TypeScript thinks 'user' is 'any' - can't catch these errors:
console.log(user.nmae); // ❌ Typo!
console.log(user.address.street); // ❌ Property might not exist
return user;
}
Step 2: Adding Basic Types
Let's add types to our operations:
// Define your context and domain types
interface AppContext {
db: {
findUser(id: number): Promise<User | null>;
updateUser(id: number, data: Partial<User>): Promise<User>;
};
logger: {
info(message: string): void;
};
}
interface User {
id: number;
name: string;
email: string;
}
// Type for operations
type Operation<T> = (context: AppContext) => Promise<T>;
// Typed operations
const getUser = (id: number): Operation<User | null> =>
(ctx) => ctx.db.findUser(id);
const log = (message: string): Operation<void> =>
(ctx) => ctx.logger.info(message);
Step 3: The Simple Solution - Type Assertions
TypeScript loses track of types through yield, but we can fix this with type assertions:
// ✅ SOLUTION: Tell TypeScript what type comes back
async function* updateUserWorkflow(userId: number) {
yield log('Starting update');
// Add a type assertion after yield
const user = (yield getUser(userId)) as User | null;
// ^--- TypeScript now knows the type! ✅
if (!user) {
throw new Error('User not found');
}
// Full type safety and autocomplete!
console.log(user.name); // ✅ TypeScript helps us here
const updated = (yield updateUser(userId, {
name: 'Jane'
})) as User;
return updated;
}
Step 4: Type-Safe Runtime
Finally, let's add types to our runtime. This ensures type safety all the way through:
// Generic workflow type
// - First param: what we yield (operations)
// - Second param: what we return at the end
// - Third param: what comes back from yields (we use any)
type Workflow<T> = AsyncGenerator<Operation<any>, T, any>;
// Type-safe runtime with full generic support
export function runtime<TArgs extends any[], TReturn>(
generatorFunction: (...args: TArgs) => Workflow<TReturn>
) {
// Return an executor function that:
// 1. Takes the context and any workflow arguments
// 2. Returns a Promise of the workflow's return type
return async function execute(
context: AppContext,
...args: TArgs
): Promise<TReturn> {
const generator = generatorFunction(...args);
let result = await generator.next();
while (!result.done) {
const operation = result.value as Operation<any>;
try {
const operationResult = await operation(context);
result = await generator.next(operationResult);
} catch (error) {
result = await generator.throw(error);
}
}
return result.value;
};
}
// Usage - everything is fully typed!
const updateUser = runtime(updateUserWorkflow);
// updateUser is typed as: (context: AppContext, userId: number) => Promise<User>
const user = await updateUser(context, 123);
// user is typed as: User ✅
That's it! With simple type assertions, you get full type safety. Your IDE will provide autocomplete, catch typos, and ensure type correctness throughout your workflows. However, that's not the full or ideal story to achieving type safty.
Want Even Better? Use yield* for Automatic Type Inference
There's a clever way to avoid type assertions entirely using yield* delegation. Here's how it works:
Understanding yield vs yield*
// yield: Pauses and sends out a value
function* example1() {
const result = yield 'hello';
// TypeScript doesn't know what 'result' is
}
// yield*: Delegates to another generator
function* inner() {
yield 'hello';
return 42; // This return value matters!
}
function* example2() {
const result = yield* inner();
// TypeScript DOES know result is 42!
}
The key insight: yield* runs another generator to completion and gives you its return value. TypeScript can track this!
The Magic $ Helper
We can use this to create a tiny helper that preserves types:
// A tiny generator that yields our operation and returns the result
function* $(operation: Operation<any>) {
// This generator:
// 1. Yields the operation (for the runtime to execute)
// 2. Returns whatever gets passed back
return yield operation;
}
// Overloaded signature for perfect type inference
function $<T>(operation: Operation<T>): Generator<Operation<T>, T, T>;
function $(operation: Operation<any>) {
return (function* () {
return yield operation;
})();
}
Using the $ Helper
Now your workflows need NO type assertions:
// ✅ PERFECT: Full type inference, no assertions!
async function* updateUserWorkflow(userId: number) {
// yield* runs the $ generator and gets its RETURN value
const user = yield* $(getUser(userId));
// ^--- ✅ TypeScript knows: User | null
if (!user) {
throw new Error('User not found');
}
// Perfect type inference all the way down
console.log(user.name); // ✅ Full autocomplete
console.log(user.email); // ✅ Type safe
const updated = yield* $(updateUser(userId, {
name: 'Jane Doe'
}));
// ^--- ✅ TypeScript knows: User
return updated;
}
Why Does This Work?
Let's trace through what happens:
// Step by step:
const user = yield* $(getUser(userId));
// 1. $(getUser(userId)) creates a generator
// 2. yield* runs that generator
// 3. The generator yields getUser(userId) - runtime executes it
// 4. Runtime passes back the result (e.g., a User)
// 5. The generator returns that result
// 6. yield* gives us that return value
// 7. TypeScript tracked it all the way through!
Visual representation:
// With plain yield - TypeScript loses track:
workflow → yield operation → ??? → any
// With yield* $ - TypeScript follows the path:
workflow → yield* → $ generator → yield operation → return result → typed result!
Deeper Dive: Removing the $ Helper by Restructuring Operations
The ultimate goal is to achieve perfect type inference with the cleanest possible syntax in our business logic. We want this:
// The ideal syntax
const user = yield* getUser(123); // No helpers, no type assertions
// TypeScript should know user is of type `User | null`
To understand how to get there, let's first revisit why we needed the $ helper in the first place.
The Core Problem: TypeScript and yield
The fundamental issue is that TypeScript cannot trace the type of a value across a standard yield expression.
// When TypeScript sees this:
const myValue = yield someOperation;
// It thinks:
// 1. The `someOperation` value is being sent OUT of the generator.
// 2. At some later time, the runtime will call `generator.next(someExternalValue)`.
// 3. The `someExternalValue` will then be assigned to `myValue`.
//
// TypeScript has no way to statically connect the type of `someOperation`
// to the type of `someExternalValue` that will arrive later. It gives up
// and assigns the type `any` to `myValue`.
The yield* keyword is different. It's a language feature for delegating to another generator. TypeScript understands this delegation.
// When TypeScript sees this:
const myValue = yield* anotherGenerator();
// It thinks:
// 1. I'm going to run `anotherGenerator` to completion.
// 2. The final `return` value from `anotherGenerator` will be assigned to `myValue`.
// 3. I can look at the signature of `anotherGenerator` to find its return type!
The $ helper was a clever trick. It was a tiny generator that wrapped our simple Operation function, allowing us to use yield* and leverage its type-tracking ability.
The "Helper-less" Insight: Make the Operation Itself a Generator
If yield* requires a generator to delegate to, the most direct way to eliminate the helper is to make the operation itself a generator.
Instead of this:
getUser is a function that returns an Operation.
// Old way: A factory for operations
const getUser = (id: number): Operation<User | null> =>
(ctx) => ctx.db.findUser(id);
We change it to this:
getUser is a generator.
// New way: The operation IS a generator
function* getUser(id: number): Generator<..., User | null, ...> {
// ... logic ...
}
Now our workflow can delegate directly to it: yield* getUser(123). But this raises a new, critical question.
The New Challenge: How Does the Generator Operation Get the Context?
Our original Operation was a simple function that received the context: (ctx) => ....
Our new getUser generator is now running "inside" the yield* expression. It doesn't automatically have access to the context that lives in the runtime. So, how does it get it?
It has to ask for it.
And how does a generator ask for something from the outside world? By yielding a value.
This is the most crucial part of the pattern. The generator operation must perform a two-step dance:
- Yield a special request that tells the runtime, "Please give me the context object."
- Wait for the runtime to send the context back via
.next(context).
The Mechanism: The "Context Request" Signal
We need to design a "signal" that the generator can yield. The simplest signal is a function that the runtime can execute with the context. What's the most direct function to get the context itself? The identity function: (context) => context.
Let's trace the complete flow:
Step 1: The Generator Operation (getUser)
We define getUser as a generator that performs this dance.
// The restructured generator operation
function* getUser(id: number): Generator<
(ctx: AppContext) => AppContext, // 1. I will YIELD a function that takes context and returns context.
Promise<User | null>, // 2. I will RETURN a Promise that resolves to a User.
AppContext // 3. I EXPECT to receive the AppContext back via .next().
> {
// The first thing I do is ask for the context.
// I yield the "context request" signal (the identity function).
const ctx = yield (c: AppContext) => c;
// The runtime will send the context back, and it gets assigned to `ctx`.
// Now I have the context! I can perform my real work.
// I return the promise from the database call.
return ctx.db.findUser(id);
}
Step 2: The Workflow (workflow)
The workflow code is now beautifully clean.
async function* workflow(userId: number) {
// `yield*` delegates to the `getUser` generator.
// It handles the entire two-step "context request" dance internally.
// The final `return` value from `getUser` (the Promise) is returned by `yield*`.
const userPromise = yield* getUser(userId);
const user = await userPromise;
// ^--- We need this await because `getUser` returns a promise.
// Or more concisely:
const user = await (yield* getUser(userId));
if (!user) throw new Error('Not found');
return user;
}
Note: The await is required here because the generator operation returns a Promise, and yield* gives us that promise. This is a bit less elegant than the $ helper approach where the runtime handles the await for us.
Step 3: The Runtime (The Catch!)
Our original 16-line runtime is no longer sufficient. It only knows how to handle one type of yielded value: an Operation like (ctx) => ctx.db.findUser(...). It doesn't understand our new "context request" signal (ctx) => ctx.
The runtime must be modified to distinguish between these two types of requests.
// A conceptual modification to the runtime's loop
while (!result.done) {
const yieldedValue = result.value;
// We need a way to know if this is a context request
if (isAContextRequest(yieldedValue)) {
// If it is, don't execute it as a long operation.
// Just give the generator the context back immediately.
result = await generator.next(context);
} else {
// Otherwise, it's a normal operation.
// Execute it, await the result, and pass it back.
const operationResult = await yieldedValue(context);
result = await generator.next(operationResult);
}
}
This modification breaks the beautiful simplicity of the original runtime.
Where the Type Safety Comes From
The type safety in this helper-less approach is 100% powered by the yield* keyword and TypeScript's understanding of generator function signatures.
-
The Contract: When you write function* getUser(...): Generator<..., Promise<User | null>, ...>, you are creating a strongly-typed contract. You're telling TypeScript, "No matter what happens inside this generator, its final return value will be of type Promise<User | null>."
-
The Delegation: When the workflow executes yield* getUser(userId), TypeScript sees this and says:
- "Aha,
yield*! I'm delegating to getUser."
- "Let me check the signature of
getUser."
- "I see its declared return type is
Promise<User | null>."
- "Therefore, the result of this entire
yield* expression must be Promise<User | null>."
The type information flows directly from the generator operation's definition to the variable in the workflow, all because yield* acts as a type-safe bridge. This is fundamentally different from a plain yield, which breaks the chain of type inference.
Summary: The Final Trade-Off
Let's lay out the pros and cons clearly.
Benefits of the Helper-less Approach:
- Cleanest Workflow Syntax: The business logic (
yield* getUser(userId)) is as clean as it can possibly be, with no helpers or assertions.
Downsides of the Helper-less Approach:
- Complex Operations: Every single operation must be converted from a simple, pure function into a more complex generator that does the "context request" dance. This adds boilerplate and cognitive overhead to every operation.
- Modified Runtime: You lose the elegant, universal 16-line runtime. Your runtime now needs special-case logic to handle different kinds of yielded values.
- Harder to Understand: The "magic" is now distributed. A developer needs to understand both the structure of generator operations and the special logic in the runtime. The cause-and-effect relationship is less direct.
- Awkward
await (yield* ...) syntax: The need to manually await the result of the yield* expression is less ergonomic.
Which Approach Should You Use?
Use type assertions if:
- You want to keep things simple
- You don't mind the manual assertions
- Your team is still learning the pattern
const user = (yield getUser(id)) as User | null;
Use the $ helper if:
- You want perfect type inference
- You prefer cleaner code
- You're comfortable with
yield*
const user = yield* $(getUser(id));
Both approaches give you full type safety. The helper approach is more elegant but requires understanding yield* delegation. Choose what works best for your team.
The Bottom Line
With TypeScript, our 16-line pattern becomes even more powerful. Whether you use simple type assertions or the advanced yield* approach, you get:
- 🎯 Compile-time safety - Catch errors before runtime
- 🚀 Perfect autocomplete - Your IDE knows every property
- 🔧 Refactoring confidence - Change types and TypeScript guides you
- 📚 Self-documenting code - Types are your documentation
The pattern that eliminates dependency passing also eliminates type uncertainty. That's the power of good design - it works beautifully at every level.
Composing Workflows
Real applications need to compose smaller workflows:
// Helper to run sub-workflows
const runWorkflow = (workflowGenerator) => async (context) => {
const execute = runtime(workflowGenerator);
return execute(context);
};
// Compose workflows
async function* createUserWithProfile(userData, profileData) {
yield log('Creating user account');
// Run sub-workflow
const user = yield runWorkflow(function* () {
yield log('Validating user data');
const isValid = yield validateUserData(userData);
if (!isValid) throw new Error('Invalid user data');
const newUser = yield createUser(userData);
yield sendWelcomeEmail(newUser.email);
return newUser;
});
// Run another sub-workflow
const profile = yield runWorkflow(function* () {
yield log('Creating user profile');
const newProfile = yield createProfile(user.id, profileData);
yield indexProfileForSearch(newProfile);
return newProfile;
});
yield log('User and profile created');
return { user, profile };
}
🤔 Check Your Understanding #3
- What's the difference between
yield getUser(1) and yield runWorkflow(...)?
- Why do we need the
runWorkflow helper?
- Can workflows call other workflows recursively?
Answers
getUser(1) yields an operation, runWorkflow(...) yields a workflow executor
- To execute sub-generators within our main generator
- Yes! Workflows can call other workflows, enabling full composition
When to Use This Pattern
✅ Use it when:
- You have complex business workflows with many dependencies
- You're tired of dependency passing through multiple layers
- You want highly testable code
- You need to swap implementations (test vs production)
- You're building multi-step processes
- You want clean separation of concerns
❌ Don't use it when:
- You have simple, flat code structure
- Your team isn't comfortable with generators
- You're building a library (generators aren't tree-shakeable)
- You need to support very old browsers
Your Next Steps
- Copy the 16-line runtime from this article
- Convert one function in your codebase that has dependency passing
- Create your first operations - start with logging
- Build your operation library for your domain
- Share your experience - what worked? what didn't?
Here's a starter template:
// runtime.js - The 16-line runtime
export function runtime(generatorFunction) {
return async function execute(context) {
const generator = generatorFunction();
let result = await generator.next();
while (!result.done) {
const operation = result.value;
try {
const operationResult = await operation(context);
result = await generator.next(operationResult);
} catch (error) {
result = await generator.throw(error);
}
}
return result.value;
};
}
// operations.js - Your operation library
export const log = (msg) => (ctx) => ctx.logger.info(msg);
export const getUser = (id) => (ctx) => ctx.db.users.findById(id);
export const createUser = (data) => (ctx) => ctx.db.users.create(data);
// Add more as needed...
// workflows/user.js - Your business logic
import { log, getUser, createUser } from '../operations.js';
export async function* createUserWorkflow(userData) {
yield log('Creating new user');
const existingUser = yield getUser(userData.email);
if (existingUser) {
throw new Error('User already exists');
}
const user = yield createUser(userData);
yield log(`User ${user.id} created`);
return user;
}
// app.js - Wire it together
import { runtime } from './runtime.js';
import { createUserWorkflow } from './workflows/user.js';
const context = {
logger: console,
db: {
users: {
findById: async (id) => {/* your implementation */},
create: async (data) => {/* your implementation */}
}
}
};
const createUser = runtime(() =>
createUserWorkflow({ email: 'test@example.com', name: 'Test' })
);
const user = await createUser(context);
Typescript version
// ============================================================================
// The Generator/Runtime Pattern: The "Helper-less" Type-Safe Implementation
//
// This version achieves perfect type inference with no helpers or assertions.
// The trade-off: operations and the runtime are more complex, but the
// business logic in the workflow is exceptionally clean.
// ============================================================================
// 1. CORE TYPES: Define the contracts for our application.
/** A simple domain model for a user. */
interface User {
id: number;
name: string;
}
/** The dependency container holding all external services. */
interface AppContext {
db: {
findUserById(id: number): Promise<User | null>;
};
logger: {
info(message: string): void;
};
}
// 2. THE CONTEXT REQUEST: A special signal for requesting the context.
// Instead of being passed around, the context is now explicitly requested by
// operations when they need it.
/** A typed function that represents a request for the context. */
type ContextRequest = (context: AppContext) => AppContext;
/** A constant holding the actual request signal. We check for this in the runtime. */
const GetContext: ContextRequest = (context) => context;
// 3. THE "SMART" RUNTIME: The modified engine that understands context requests.
// This runtime is no longer a generic "operation executor". It now has special logic
// to handle the GetContext signal, which is the only thing operations are allowed to yield.
/**
* The modified runtime that understands how to inject the context into operations.
* @param generatorFn The generator function defining the business logic.
* @returns An async function that takes a context and arguments, then runs the workflow.
*/
function runtime<TArgs extends any[], TReturn>(
generatorFn: (...args: TArgs) => AsyncGenerator<ContextRequest, TReturn, AppContext>
) {
return async function execute(
context: AppContext,
...args: TArgs
): Promise<TReturn> {
const generator = generatorFn(...args);
// Start the generator. It will run until the first `yield GetContext`.
let result = await generator.next();
// Loop until the generator is done.
while (!result.done) {
const yieldedValue = result.value;
// This is the "catch" from your prompt, now implemented.
// We check if the yielded value is our specific context request signal.
if (yieldedValue === GetContext) {
// If it is, we don't execute it. We just send the real context
// back into the generator so it can continue its work.
result = await generator.next(context);
} else {
// In this pure pattern, no other values should be yielded.
// A more complex system could handle other signals here.
throw new Error("Invalid value yielded. Only GetContext is allowed.");
}
}
return result.value;
};
}
// 4. GENERATOR OPERATIONS: The application's "vocabulary" as generators.
// Operations are no longer simple functions. They are now generators themselves
// that follow a specific two-step pattern:
// 1. `yield` the `GetContext` signal.
// 2. Receive the context and `return` the final result (often a Promise).
/**
* The log operation, redefined as a generator.
* Its signature declares what it yields, what it returns, and what it expects back.
*/
function* log(message: string): Generator<ContextRequest, void, AppContext> {
// Step 1: Ask for the context.
const ctx = yield GetContext;
// Step 2: Use the context.
ctx.logger.info(message);
}
/**
* The findUserById operation, redefined as a generator.
* It returns a Promise, which the workflow will need to `await`.
*/
function* findUserById(id: number): Generator<ContextRequest, Promise<User | null>, AppContext> {
const ctx = yield GetContext;
// The `return` keyword is crucial. It's what `yield*` will evaluate to.
return ctx.db.findUserById(id);
}
// 5. THE WORKFLOW: The business logic, now exceptionally clean.
// It uses `yield*` to delegate directly to the generator operations.
// This is where the type safety from generator signatures pays off.
/** A workflow to find a user and greet them. It must be `async`. */
async function* findAndGreetWorkflow(userId: number) {
// `yield*` runs the `log` generator to completion.
yield* log(`Searching for user with ID: ${userId}...`);
// `yield*` delegates to `findUserById`. TypeScript knows its return type is
// `Promise<User | null>`, so we must `await` it.
const user = await (yield* findUserById(userId));
if (!user) {
yield* log(`User not found.`);
return null;
}
const greeting = `Hello, ${user.name}!`;
yield* log(greeting);
return greeting;
}
Conclusion
In just 16 lines of runtime code, we've built a powerful pattern that eliminates entire categories of problems. The beauty is in its simplicity: generators pause, the runtime provides what they need, they resume.
This isn't just a clever trick - it's a fundamental architectural pattern that scales from simple scripts to complex applications. The same principle that powers Redux-Saga, Effect-TS, and other production systems is now yours to use.
The key insight is the shift from PUSH to PULL:
- PUSH: Force dependencies through every function
- PULL: Let functions request what they need
This simple change transforms how you structure applications. No more dependency passing through layers of functions. No more tightly coupled code. Just clean, testable, composable workflows.
Start small. Copy the runtime. Convert one function. Experience the freedom.
Found this useful? Share it with your team. The pattern is powerful, but it's even better when everyone understands it.
I like the simplicity of this pattern. Maybe we can adopt it.
Archived:
Details
The 16-Line Pattern That Eliminates Dependency Passing
Learn how a tiny runtime using JavaScript generators can transform how you structure applications
Building Intuition: The Restaurant Analogy
Imagine two restaurants:
Restaurant A (Dependency Passing)
Restaurant B (Our Pattern)
This is the shift we're making in code.
The Problem: Dependency Passing Hell
Let's see this in code you've probably written:
What is Dependency Passing?
This pattern of passing dependencies through multiple function layers is known as "dependency passing" or "parameter threading". In React, it's called "prop drilling", but the problem exists in all JavaScript code - functions receiving dependencies they don't use, just to pass them to other functions.
Visualizing the Pain:
The Mind-Shift: From PUSH to PULL
Here's the key insight that changes everything:
The Pattern Visualized:
"Why Not Just..." - Addressing the Alternatives
You might be thinking of alternatives. Let's address them:
Why not just use a global object?
Why not use classes and constructor injection?
Why not use React Context or similar?
Our solution achieves the same goals with just 16 lines of vanilla JavaScript.
Understanding Generators: The Pause/Resume Mechanism
Before we build our solution, let's understand generators:
Output:
🤔 Check Your Understanding #1
Before continuing, make sure you can answer:
yield?Answers
yield, returning control to the calleryieldpauses the generator and sends out a valuegen.next(value)- the value becomes the result of the yieldBuilding the Pattern Step by Step
Step 1: The Operation Description Pattern
Let's build up to our pattern progressively:
This creates a description of an operation without executing it:
Step 2: Building the Runtime - Basic Version
Let's build our runtime step by step to understand how it works:
🤔 Check Your Understanding #2
operation(context)do?Answers
## Complete Working Example
Let's see it all together:
Side-by-Side Comparison
Let's see the difference clearly:
Error Handling: Complete Understanding
Let's deeply understand how errors flow through the system:
Exercise: Convert This Code
Here's a real function that suffers from dependency passing. Try converting it yourself:
✅ Solution: Using the generator pattern
Notice how the generator version:
Making Sense of It: How This Connects to What You Know
This pattern isn't as strange as it looks. It’s just a clever mix of ideas you've probably already seen.
Like Express Middleware
Both patterns pass control. But
yieldkeeps your logic clean by avoiding the need to pass a messyreqobject everywhere.Like Async/Await
It's the same "pause and resume" mechanic.
awaitpauses for time;yieldpauses for a dependency.Like a DI Container
You get the main benefit of a big Dependency Injection framework—decoupled code—without any of the complexity.
It avoids Node-only features like
AsyncLocalStorage, it's explicit instead of magical, and it’s easy to type-check and debug.Real-World Case Study: Redux-Saga
Redux-Saga, used by millions, implements this exact pattern:
The pattern you're learning powers production systems handling millions of requests.
Gradual Migration Guide
You don't need to rewrite everything at once. Here's how to migrate gradually:
Step 1: Start with Leaf Functions
Step 2: Create a Hybrid Wrapper
Step 3: Update Callers One by One
Step 4: Remove Old Code Path
Once all callers are updated, remove the old code path and fully embrace the pattern.
Why This Works: Inversion of Control
This pattern implements a fundamental principle called "Inversion of Control" (IoC):
Traditional Control Flow:
Inverted Control Flow:
This is the same principle behind:
useContext,useState)But implemented in just 16 lines of vanilla JavaScript!
Testing: Now It's Trivial
Testing becomes incredibly simple:
TypeScript
The pattern works beautifully with TypeScript, but getting proper type inference requires understanding one quirk of generators. Let's build up to a fully type-safe solution, step by step.
Step 1: The Problem We're Solving
Without proper typing, TypeScript can't help us catch errors:
Step 2: Adding Basic Types
Let's add types to our operations:
Step 3: The Simple Solution - Type Assertions
TypeScript loses track of types through
yield, but we can fix this with type assertions:Step 4: Type-Safe Runtime
Finally, let's add types to our runtime. This ensures type safety all the way through:
That's it! With simple type assertions, you get full type safety. Your IDE will provide autocomplete, catch typos, and ensure type correctness throughout your workflows. However, that's not the full or ideal story to achieving type safty.
Want Even Better? Use yield* for Automatic Type Inference
There's a clever way to avoid type assertions entirely using
yield*delegation. Here's how it works:Understanding yield vs yield*
The key insight:
yield*runs another generator to completion and gives you its return value. TypeScript can track this!The Magic $ Helper
We can use this to create a tiny helper that preserves types:
Using the $ Helper
Now your workflows need NO type assertions:
Why Does This Work?
Let's trace through what happens:
Visual representation:
Deeper Dive: Removing the
$Helper by Restructuring OperationsThe ultimate goal is to achieve perfect type inference with the cleanest possible syntax in our business logic. We want this:
To understand how to get there, let's first revisit why we needed the
$helper in the first place.The Core Problem: TypeScript and
yieldThe fundamental issue is that TypeScript cannot trace the type of a value across a standard
yieldexpression.The
yield*keyword is different. It's a language feature for delegating to another generator. TypeScript understands this delegation.The
$ helperwas a clever trick. It was a tiny generator that wrapped our simpleOperationfunction, allowing us to useyield*and leverage its type-tracking ability.The "Helper-less" Insight: Make the Operation Itself a Generator
If
yield*requires a generator to delegate to, the most direct way to eliminate the helper is to make the operation itself a generator.Instead of this:
getUseris a function that returns anOperation.We change it to this:
getUseris a generator.Now our workflow can delegate directly to it:
yield* getUser(123). But this raises a new, critical question.The New Challenge: How Does the Generator Operation Get the Context?
Our original
Operationwas a simple function that received the context:(ctx) => ....Our new
getUsergenerator is now running "inside" theyield*expression. It doesn't automatically have access to thecontextthat lives in the runtime. So, how does it get it?It has to ask for it.
And how does a generator ask for something from the outside world? By yielding a value.
This is the most crucial part of the pattern. The generator operation must perform a two-step dance:
.next(context).The Mechanism: The "Context Request" Signal
We need to design a "signal" that the generator can yield. The simplest signal is a function that the runtime can execute with the context. What's the most direct function to get the context itself? The identity function:
(context) => context.Let's trace the complete flow:
Step 1: The Generator Operation (
getUser)We define
getUseras a generator that performs this dance.Step 2: The Workflow (
workflow)The workflow code is now beautifully clean.
Note: The
awaitis required here because the generator operationreturnsaPromise, andyield*gives us that promise. This is a bit less elegant than the$ helperapproach where the runtime handles theawaitfor us.Step 3: The Runtime (The Catch!)
Our original 16-line runtime is no longer sufficient. It only knows how to handle one type of yielded value: an
Operationlike(ctx) => ctx.db.findUser(...). It doesn't understand our new "context request" signal(ctx) => ctx.The runtime must be modified to distinguish between these two types of requests.
This modification breaks the beautiful simplicity of the original runtime.
Where the Type Safety Comes From
The type safety in this helper-less approach is 100% powered by the
yield*keyword and TypeScript's understanding of generator function signatures.The Contract: When you write
function* getUser(...): Generator<..., Promise<User | null>, ...>, you are creating a strongly-typed contract. You're telling TypeScript, "No matter what happens inside this generator, its finalreturnvalue will be of typePromise<User | null>."The Delegation: When the workflow executes
yield* getUser(userId), TypeScript sees this and says:yield*! I'm delegating togetUser."getUser."Promise<User | null>."yield*expression must bePromise<User | null>."The type information flows directly from the generator operation's definition to the variable in the workflow, all because
yield*acts as a type-safe bridge. This is fundamentally different from a plainyield, which breaks the chain of type inference.Summary: The Final Trade-Off
Let's lay out the pros and cons clearly.
Benefits of the Helper-less Approach:
yield* getUser(userId)) is as clean as it can possibly be, with no helpers or assertions.Downsides of the Helper-less Approach:
await (yield* ...)syntax: The need to manuallyawaitthe result of theyield*expression is less ergonomic.Which Approach Should You Use?
Use type assertions if:
Use the $ helper if:
yield*Both approaches give you full type safety. The helper approach is more elegant but requires understanding
yield*delegation. Choose what works best for your team.The Bottom Line
With TypeScript, our 16-line pattern becomes even more powerful. Whether you use simple type assertions or the advanced
yield*approach, you get:The pattern that eliminates dependency passing also eliminates type uncertainty. That's the power of good design - it works beautifully at every level.
Composing Workflows
Real applications need to compose smaller workflows:
🤔 Check Your Understanding #3
yield getUser(1)andyield runWorkflow(...)?runWorkflowhelper?Answers
getUser(1)yields an operation,runWorkflow(...)yields a workflow executorWhen to Use This Pattern
✅ Use it when:
❌ Don't use it when:
Your Next Steps
Here's a starter template:
Typescript version
Conclusion
In just 16 lines of runtime code, we've built a powerful pattern that eliminates entire categories of problems. The beauty is in its simplicity: generators pause, the runtime provides what they need, they resume.
This isn't just a clever trick - it's a fundamental architectural pattern that scales from simple scripts to complex applications. The same principle that powers Redux-Saga, Effect-TS, and other production systems is now yours to use.
The key insight is the shift from PUSH to PULL:
This simple change transforms how you structure applications. No more dependency passing through layers of functions. No more tightly coupled code. Just clean, testable, composable workflows.
Start small. Copy the runtime. Convert one function. Experience the freedom.
Found this useful? Share it with your team. The pattern is powerful, but it's even better when everyone understands it.