Skip to content

Commit 01687ab

Browse files
authored
refactor: change step handler input to use asymmetric pattern for better type safety (#556)
# Asymmetric Step Input Pattern for Improved Type Safety and Composition This PR introduces a significant improvement to the step input pattern across the PgFlow DSL: - Root steps now receive flow input directly (no `run` wrapper) - Dependent steps receive only their dependencies (flow input available via `context.flowInput`) This change enables better functional composition where subflows can receive typed inputs without the `run` wrapper that previously blocked type matching. The asymmetric input pattern makes the API more intuitive while maintaining full type safety. ## Key Changes: - Updated `StepInput` utility type to implement the asymmetric pattern - Modified step handler signatures to use `flowInput` instead of `input.run` - Added `flowInput` to the `FlowContext` interface for dependent steps - Updated all tests to use the new pattern - Improved type tests to verify the asymmetric behavior This change is backward compatible at runtime but will require updating handler parameter names in client code. The improved type safety and composability are worth the minor migration effort.
1 parent ede7720 commit 01687ab

31 files changed

+606
-580
lines changed

pkgs/client/__tests__/types/client-basic.test-d.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ const AnalyzeWebsite = new Flow<{ url: string }>({
2020
baseDelay: 5,
2121
timeout: 10,
2222
})
23-
.step({ slug: 'website' }, (input) => ({
24-
content: `Content for ${input.run.url}`,
23+
.step({ slug: 'website' }, (flowInput) => ({
24+
content: `Content for ${flowInput.url}`,
2525
}))
2626
.step({ slug: 'sentiment', dependsOn: ['website'] }, (_input) => ({
2727
score: 0.75,
@@ -150,12 +150,16 @@ describe('PgflowClient Type Tests', () => {
150150
.resolves.toHaveProperty('output')
151151
.toEqualTypeOf<SentimentOutput | null>();
152152

153-
// Check input type for a step
153+
// Check input type for a step - dependent steps only get their deps (no run key)
154+
// flowInput is available via context.flowInput for dependent steps
154155
type SentimentInput = StepInput<typeof AnalyzeWebsite, 'sentiment'>;
155156
expectTypeOf<SentimentInput>().toMatchTypeOf<{
156-
run: { url: string };
157157
website: { content: string };
158158
}>();
159+
// Verify it does NOT have run key (asymmetric input)
160+
expectTypeOf<SentimentInput>().not.toMatchTypeOf<{
161+
run: { url: string };
162+
}>();
159163
});
160164

161165
it('should properly type event subscription', async () => {

pkgs/client/__tests__/unit/concurrent-operations.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { RUN_ID, FLOW_SLUG, STEP_SLUG, startedRunSnapshot, stepStatesSample } fr
2323
// Create a test flow for proper typing
2424
const TestFlow = new Flow<{ test: string }>({ slug: 'test_flow' }).step(
2525
{ slug: 'test_step' },
26-
(input) => ({ result: input.run.test })
26+
(flowInput) => ({ result: flowInput.test })
2727
);
2828

2929
// Mock uuid to return predictable IDs

pkgs/dsl/__tests__/types/array-method.test-d.ts

Lines changed: 44 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -47,49 +47,48 @@ describe('.array() method type constraints', () => {
4747
describe('type inference', () => {
4848
it('should provide correct input types for dependent steps', () => {
4949
new Flow<{ count: number }>({ slug: 'test' })
50-
.array({ slug: 'items' }, ({ run }) => Array(run.count).fill(0).map((_, i) => i))
51-
.step({ slug: 'process', dependsOn: ['items'] }, (input) => {
52-
expectTypeOf(input).toMatchTypeOf<{
53-
run: { count: number };
50+
.array({ slug: 'items' }, (flowInput) => Array(flowInput.count).fill(0).map((_, i) => i))
51+
.step({ slug: 'process', dependsOn: ['items'] }, (deps) => {
52+
expectTypeOf(deps).toMatchTypeOf<{
5453
items: number[];
5554
}>();
56-
return input.items.length;
55+
return deps.items.length;
5756
});
5857
});
5958

6059
it('should correctly infer element types from arrays', () => {
6160
new Flow<{ userId: string }>({ slug: 'test' })
6261
.array({ slug: 'users' }, () => [{ id: 1, name: 'John' }, { id: 2, name: 'Jane' }])
63-
.step({ slug: 'count_users', dependsOn: ['users'] }, (input) => {
64-
expectTypeOf(input.users).toEqualTypeOf<{ id: number; name: string }[]>();
65-
expectTypeOf(input.users[0]).toMatchTypeOf<{ id: number; name: string }>();
66-
return input.users.length;
62+
.step({ slug: 'count_users', dependsOn: ['users'] }, (deps) => {
63+
expectTypeOf(deps.users).toEqualTypeOf<{ id: number; name: string }[]>();
64+
expectTypeOf(deps.users[0]).toMatchTypeOf<{ id: number; name: string }>();
65+
return deps.users.length;
6766
});
6867
});
6968

7069
it('should handle complex nested array types', () => {
7170
new Flow<{ depth: number }>({ slug: 'test' })
72-
.array({ slug: 'matrix' }, ({ run }) =>
73-
Array(run.depth).fill(0).map(() => Array(3).fill(0).map(() => ({ value: Math.random() })))
71+
.array({ slug: 'matrix' }, (flowInput) =>
72+
Array(flowInput.depth).fill(0).map(() => Array(3).fill(0).map(() => ({ value: Math.random() })))
7473
)
75-
.step({ slug: 'flatten', dependsOn: ['matrix'] }, (input) => {
76-
expectTypeOf(input.matrix).toEqualTypeOf<{ value: number }[][]>();
77-
expectTypeOf(input.matrix[0]).toEqualTypeOf<{ value: number }[]>();
78-
expectTypeOf(input.matrix[0][0]).toMatchTypeOf<{ value: number }>();
79-
return input.matrix.flat();
74+
.step({ slug: 'flatten', dependsOn: ['matrix'] }, (deps) => {
75+
expectTypeOf(deps.matrix).toEqualTypeOf<{ value: number }[][]>();
76+
expectTypeOf(deps.matrix[0]).toEqualTypeOf<{ value: number }[]>();
77+
expectTypeOf(deps.matrix[0][0]).toMatchTypeOf<{ value: number }>();
78+
return deps.matrix.flat();
8079
});
8180
});
8281

8382
it('should correctly type async array handlers', () => {
8483
new Flow<{ url: string }>({ slug: 'test' })
85-
.array({ slug: 'data' }, async ({ run }) => {
84+
.array({ slug: 'data' }, async (flowInput) => {
8685
// Simulate async data fetching
8786
await new Promise(resolve => setTimeout(resolve, 1));
88-
return [{ url: run.url, status: 200 }];
87+
return [{ url: flowInput.url, status: 200 }];
8988
})
90-
.step({ slug: 'validate', dependsOn: ['data'] }, (input) => {
91-
expectTypeOf(input.data).toEqualTypeOf<{ url: string; status: number }[]>();
92-
return input.data.every(item => item.status === 200);
89+
.step({ slug: 'validate', dependsOn: ['data'] }, (deps) => {
90+
expectTypeOf(deps.data).toEqualTypeOf<{ url: string; status: number }[]>();
91+
return deps.data.every(item => item.status === 200);
9392
});
9493
});
9594
});
@@ -106,33 +105,31 @@ describe('.array() method type constraints', () => {
106105
new Flow<string>({ slug: 'test' })
107106
.array({ slug: 'items1' }, () => [1, 2, 3])
108107
.array({ slug: 'items2' }, () => ['a', 'b', 'c'])
109-
.array({ slug: 'combined', dependsOn: ['items1'] }, (input) => {
110-
expectTypeOf(input).toMatchTypeOf<{
111-
run: string;
108+
.array({ slug: 'combined', dependsOn: ['items1'] }, (deps) => {
109+
expectTypeOf(deps).toMatchTypeOf<{
112110
items1: number[];
113111
}>();
114112

115113
// Verify that items2 is not accessible
116-
expectTypeOf(input).not.toHaveProperty('items2');
114+
expectTypeOf(deps).not.toHaveProperty('items2');
117115

118-
return input.items1.map(String);
116+
return deps.items1.map(String);
119117
});
120118
});
121119

122120
it('should correctly type multi-dependency array steps', () => {
123121
new Flow<{ base: number }>({ slug: 'test' })
124-
.array({ slug: 'numbers' }, ({ run }) => [run.base, run.base + 1])
122+
.array({ slug: 'numbers' }, (flowInput) => [flowInput.base, flowInput.base + 1])
125123
.array({ slug: 'letters' }, () => ['a', 'b'])
126-
.array({ slug: 'combined', dependsOn: ['numbers', 'letters'] }, (input) => {
127-
expectTypeOf(input).toMatchTypeOf<{
128-
run: { base: number };
124+
.array({ slug: 'combined', dependsOn: ['numbers', 'letters'] }, (deps) => {
125+
expectTypeOf(deps).toMatchTypeOf<{
129126
numbers: number[];
130127
letters: string[];
131128
}>();
132-
133-
return input.numbers.map((num, i) => ({
129+
130+
return deps.numbers.map((num, i) => ({
134131
number: num,
135-
letter: input.letters[i] || 'z'
132+
letter: deps.letters[i] || 'z'
136133
}));
137134
});
138135
});
@@ -142,13 +139,13 @@ describe('.array() method type constraints', () => {
142139
it('should provide custom context via Flow type parameter', () => {
143140
// eslint-disable-next-line @typescript-eslint/no-explicit-any
144141
const flow = new Flow<{ id: number }, { api: { get: (id: number) => Promise<any> } }>({ slug: 'test' })
145-
.array({ slug: 'fetch_data' }, (input, context) => {
142+
.array({ slug: 'fetch_data' }, (flowInput, context) => {
146143
// No handler annotation needed! Type parameter provides context
147144
expectTypeOf(context.api).toEqualTypeOf<{ get: (id: number) => Promise<any> }>();
148145
expectTypeOf(context.env).toEqualTypeOf<Record<string, string | undefined>>();
149146
expectTypeOf(context.shutdownSignal).toEqualTypeOf<AbortSignal>();
150147

151-
return [{ id: input.run.id, data: 'mock' }];
148+
return [{ id: flowInput.id, data: 'mock' }];
152149
});
153150

154151
// ExtractFlowContext should include FlowContext & custom resources
@@ -164,11 +161,11 @@ describe('.array() method type constraints', () => {
164161

165162
it('should share custom context across array and regular steps', () => {
166163
const flow = new Flow<{ count: number }, { generator: () => number; processor: (items: number[]) => string }>({ slug: 'test' })
167-
.array({ slug: 'items' }, (input, context) => {
164+
.array({ slug: 'items' }, (flowInput, context) => {
168165
// All steps get the same context automatically
169-
return Array(input.run.count).fill(0).map(() => context.generator());
166+
return Array(flowInput.count).fill(0).map(() => context.generator());
170167
})
171-
.step({ slug: 'process' }, (input, context) => {
168+
.step({ slug: 'process' }, (flowInput, context) => {
172169
return context.processor([1, 2, 3]);
173170
});
174171

@@ -187,21 +184,21 @@ describe('.array() method type constraints', () => {
187184
describe('handler signature validation', () => {
188185
it('should correctly type array step handlers when using getStepDefinition', () => {
189186
const flow = new Flow<{ size: number }>({ slug: 'test' })
190-
.array({ slug: 'data' }, (input, _context) => Array(input.run.size).fill(0).map((_, i) => ({ index: i })))
191-
.step({ slug: 'dependent', dependsOn: ['data'] }, (input, _context) => input.data.length);
187+
.array({ slug: 'data' }, (flowInput, _context) => Array(flowInput.size).fill(0).map((_, i) => ({ index: i })))
188+
.step({ slug: 'dependent', dependsOn: ['data'] }, (deps, _context) => deps.data.length);
192189

193190
const arrayStep = flow.getStepDefinition('data');
194191

195-
// Test array step handler type - handlers have 2 params (input, context)
192+
// Test array step handler type - root steps receive flowInput directly (no run key)
196193
expectTypeOf(arrayStep.handler).toBeFunction();
197-
expectTypeOf(arrayStep.handler).parameter(0).toMatchTypeOf<{ run: { size: number } }>();
194+
expectTypeOf(arrayStep.handler).parameter(0).toMatchTypeOf<{ size: number }>();
198195
expectTypeOf(arrayStep.handler).returns.toMatchTypeOf<
199196
{ index: number }[] | Promise<{ index: number }[]>
200197
>();
201198

202199
const dependentStep = flow.getStepDefinition('dependent');
200+
// Dependent steps receive deps only (no run key)
203201
expectTypeOf(dependentStep.handler).parameter(0).toMatchTypeOf<{
204-
run: { size: number };
205202
data: { index: number }[];
206203
}>();
207204
});
@@ -213,20 +210,20 @@ describe('.array() method type constraints', () => {
213210
.array({ slug: 'items' }, () => [{ id: 1 }, { id: 2 }])
214211
.array({ slug: 'processed', dependsOn: ['items'] }, () => ['a', 'b']);
215212

216-
// Test StepInput type extraction
213+
// Test StepInput type extraction - root steps get flow input directly
217214
type ItemsInput = StepInput<typeof flow, 'items'>;
218215
expectTypeOf<ItemsInput>().toMatchTypeOf<{
219-
run: { userId: string };
216+
userId: string;
220217
}>();
221218

219+
// Dependent steps get deps only (no run key)
222220
type ProcessedInput = StepInput<typeof flow, 'processed'>;
223221
expectTypeOf<ProcessedInput>().toMatchTypeOf<{
224-
run: { userId: string };
225222
items: { id: number }[];
226223
}>();
227224

228225
// Should not contain non-dependencies
229226
expectTypeOf<ProcessedInput>().not.toHaveProperty('nonExistent');
230227
});
231228
});
232-
});
229+
});

pkgs/dsl/__tests__/types/context-inference.test-d.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ interface TestRedis {
1515
describe('Context Type Inference Tests', () => {
1616
it('should have FlowContext by default (no custom resources)', () => {
1717
const flow = new Flow({ slug: 'minimal_flow' })
18-
.step({ slug: 'process' }, (input, context) => {
18+
.step({ slug: 'process' }, (flowInput, context) => {
1919
// Handler automatically gets FlowContext (no annotation needed!)
2020
expectTypeOf(context).toMatchTypeOf<FlowContext>();
2121
expectTypeOf(context.env).toEqualTypeOf<Record<string, string | undefined>>();
@@ -33,7 +33,7 @@ describe('Context Type Inference Tests', () => {
3333

3434
it('should provide custom context via Flow type parameter', () => {
3535
const flow = new Flow<Json, { sql: TestSql }>({ slug: 'custom_context' })
36-
.step({ slug: 'query' }, (input, context) => {
36+
.step({ slug: 'query' }, (flowInput, context) => {
3737
// No handler annotation needed! Type parameter provides context
3838
expectTypeOf(context.sql).toEqualTypeOf<TestSql>();
3939
expectTypeOf(context.env).toEqualTypeOf<Record<string, string | undefined>>();
@@ -49,13 +49,13 @@ describe('Context Type Inference Tests', () => {
4949

5050
it('should share custom context across all steps', () => {
5151
const flow = new Flow<Json, { sql: TestSql; redis: TestRedis }>({ slug: 'shared_context' })
52-
.step({ slug: 'query' }, (input, context) => {
52+
.step({ slug: 'query' }, (flowInput, context) => {
5353
// All steps get the same context automatically
5454
expectTypeOf(context.sql).toEqualTypeOf<TestSql>();
5555
expectTypeOf(context.redis).toEqualTypeOf<TestRedis>();
5656
return { users: [] };
5757
})
58-
.step({ slug: 'cache' }, (input, context) => {
58+
.step({ slug: 'cache' }, (flowInput, context) => {
5959
// Second step also has access to all resources
6060
expectTypeOf(context.sql).toEqualTypeOf<TestSql>();
6161
expectTypeOf(context.redis).toEqualTypeOf<TestRedis>();
@@ -72,20 +72,21 @@ describe('Context Type Inference Tests', () => {
7272

7373
it('should preserve existing step type inference while adding context', () => {
7474
const flow = new Flow<{ initial: number }, { multiplier: number }>({ slug: 'step_chain' })
75-
.step({ slug: 'double' }, (input, context) => {
76-
// Input inference still works
77-
expectTypeOf(input.run.initial).toEqualTypeOf<number>();
75+
.step({ slug: 'double' }, (flowInput, context) => {
76+
// Input inference still works - root step gets flow input directly
77+
expectTypeOf(flowInput.initial).toEqualTypeOf<number>();
7878
// Custom context available
7979
expectTypeOf(context.multiplier).toEqualTypeOf<number>();
80-
return { doubled: input.run.initial * 2 };
80+
return { doubled: flowInput.initial * 2 };
8181
})
82-
.step({ slug: 'format', dependsOn: ['double'] }, (input, context) => {
83-
// Dependent step has access to previous step output
84-
expectTypeOf(input.run.initial).toEqualTypeOf<number>();
85-
expectTypeOf(input.double.doubled).toEqualTypeOf<number>();
82+
.step({ slug: 'format', dependsOn: ['double'] }, (deps, context) => {
83+
// Dependent step has access to previous step output via deps
84+
expectTypeOf(deps.double.doubled).toEqualTypeOf<number>();
8685
// And still has custom context
8786
expectTypeOf(context.multiplier).toEqualTypeOf<number>();
88-
return { formatted: String(input.double.doubled) };
87+
// Access flow input via context.flowInput
88+
expectTypeOf(context.flowInput.initial).toEqualTypeOf<number>();
89+
return { formatted: String(deps.double.doubled) };
8990
});
9091

9192
// Context includes custom resources

0 commit comments

Comments
 (0)