Skip to content

Commit 8f6eb3d

Browse files
authored
Add ExtractFlowLeafSteps and ExtractFlowOutput utility types to DSL (#92)
* feat: add utility type ExtractFlowLeafSteps to extract leaf steps from a Flow - Introduced ExtractFlowLeafSteps type in dsl.ts for extracting steps that are not dependencies of others - Added comprehensive tests for ExtractFlowLeafSteps covering various flow configurations - Enhanced type safety and clarity in flow step dependency analysis * feat: add type extraction utility for Flow objects Introduce ExtractFlowOutput type to extract step output types from Flow instances, along with corresponding tests to ensure correct type inference for various flow structures, including empty flows, single-step flows, multiple steps, and complex dependency chains. Also update dsl.ts to include the new ExtractFlowOutput type. * chore: update .gitignore to exclude local Claude settings file * test: update type assertion for empty flow output in extract-flow-output tests Refactor test to use a type alias instead of creating an unused flow instance, and adjust expected type to Record<string, never> for clarity and correctness. * feat: add utility types ExtractFlowLeafSteps and ExtractFlowOutput - Introduced new utility types for flow extraction in '@pgflow/dsl' module
1 parent 4d88859 commit 8f6eb3d

File tree

5 files changed

+285
-0
lines changed

5 files changed

+285
-0
lines changed

.changeset/eleven-icons-think.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@pgflow/dsl': patch
3+
---
4+
5+
Added ExtractFlowLeafSteps and ExtractFlowOutput utility types

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,5 @@ tsconfig.vitest-temp.json
4949
deno-dist/
5050
.nx-inputs
5151
.vscode
52+
53+
**/.claude/settings.local.json
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { ExtractFlowLeafSteps, Flow } from '../../src/index.js';
2+
import { describe, it, expectTypeOf } from 'vitest';
3+
4+
describe('ExtractFlowLeafSteps utility type', () => {
5+
it('should return never for a flow without steps', () => {
6+
const emptyFlow = new Flow({ slug: 'empty_flow' });
7+
8+
type LeafSteps = ExtractFlowLeafSteps<typeof emptyFlow>;
9+
10+
// A flow without steps should have no leaf steps
11+
expectTypeOf<LeafSteps>().toEqualTypeOf({});
12+
expectTypeOf<keyof LeafSteps>().toEqualTypeOf<never>();
13+
});
14+
15+
it('should correctly extract a single leaf step', () => {
16+
const singleStepFlow = new Flow<{ input: string }>({
17+
slug: 'single_step_flow',
18+
}).step({ slug: 'process' }, (input) => ({
19+
result: input.run.input.toUpperCase(),
20+
}));
21+
22+
type LeafSteps = ExtractFlowLeafSteps<typeof singleStepFlow>;
23+
24+
// The only step is a leaf step since no other step depends on it
25+
expectTypeOf<LeafSteps>().toMatchTypeOf<{
26+
process: { result: string };
27+
}>();
28+
29+
// Ensure it doesn't include non-existent steps
30+
expectTypeOf<LeafSteps>().not.toMatchTypeOf<{
31+
nonExistentStep: unknown;
32+
}>();
33+
});
34+
35+
it('should correctly extract multiple leaf steps', () => {
36+
const multiLeafFlow = new Flow<{ data: number }>({
37+
slug: 'multi_leaf_flow',
38+
})
39+
.step({ slug: 'intermediate' }, (input) => ({
40+
value: input.run.data * 2,
41+
}))
42+
.step({ slug: 'leaf1', dependsOn: ['intermediate'] }, (input) => ({
43+
squared: input.intermediate.value ** 2,
44+
}))
45+
.step({ slug: 'leaf2', dependsOn: ['intermediate'] }, (input) => ({
46+
doubled: input.intermediate.value * 2,
47+
}));
48+
49+
type LeafSteps = ExtractFlowLeafSteps<typeof multiLeafFlow>;
50+
51+
// Both leaf1 and leaf2 are leaf steps since no other steps depend on them
52+
expectTypeOf<LeafSteps>().toMatchTypeOf<{
53+
leaf1: { squared: number };
54+
leaf2: { doubled: number };
55+
}>();
56+
57+
// The intermediate step should not be included as it's a dependency
58+
expectTypeOf<LeafSteps>().not.toMatchTypeOf<{
59+
intermediate: { value: number };
60+
}>();
61+
});
62+
63+
it('should correctly identify a root step that is also a leaf step', () => {
64+
const rootLeafFlow = new Flow<{ input: string }>({ slug: 'root_leaf_flow' })
65+
.step({ slug: 'rootLeaf' }, (input) => ({
66+
processed: input.run.input.trim(),
67+
}))
68+
.step({ slug: 'intermediate' }, (input) => ({
69+
length: input.run.input.length,
70+
}))
71+
.step({ slug: 'dependent', dependsOn: ['intermediate'] }, (input) => ({
72+
result: input.intermediate.length > 10,
73+
}));
74+
75+
type LeafSteps = ExtractFlowLeafSteps<typeof rootLeafFlow>;
76+
77+
// rootLeaf is both a root step (no dependencies) and a leaf step (no dependents)
78+
// dependent is a leaf step (no dependents)
79+
expectTypeOf<LeafSteps>().toMatchTypeOf<{
80+
rootLeaf: { processed: string };
81+
dependent: { result: boolean };
82+
}>();
83+
84+
// intermediate should not be included as it's a dependency
85+
expectTypeOf<LeafSteps>().not.toMatchTypeOf<{
86+
intermediate: { length: number };
87+
}>();
88+
});
89+
90+
it('should handle complex dependency chains', () => {
91+
const complexFlow = new Flow<{ input: number }>({ slug: 'complex_flow' })
92+
.step({ slug: 'step1' }, (input) => ({ value: input.run.input + 1 }))
93+
.step({ slug: 'step2', dependsOn: ['step1'] }, (input) => ({
94+
value: input.step1.value * 2,
95+
}))
96+
.step({ slug: 'step3', dependsOn: ['step1'] }, (input) => ({
97+
value: input.step1.value - 1,
98+
}))
99+
.step({ slug: 'step4', dependsOn: ['step2', 'step3'] }, (input) => ({
100+
sum: input.step2.value + input.step3.value,
101+
original: input.run.input,
102+
}));
103+
104+
type LeafSteps = ExtractFlowLeafSteps<typeof complexFlow>;
105+
106+
// Only step4 is a leaf step as it's not a dependency of any other step
107+
expectTypeOf<LeafSteps>().toMatchTypeOf<{
108+
step4: { sum: number; original: number };
109+
}>();
110+
111+
// None of the intermediate steps should be included
112+
expectTypeOf<LeafSteps>().not.toMatchTypeOf<{
113+
step1: unknown;
114+
step2: unknown;
115+
step3: unknown;
116+
}>();
117+
});
118+
});
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { ExtractFlowOutput, StepOutput, Flow } from '../../src/index.js';
2+
import { describe, it, expectTypeOf } from 'vitest';
3+
4+
describe('ExtractFlowOutput utility type', () => {
5+
it('should return an empty object for a flow without steps', () => {
6+
// Use type only instead of creating an unused variable
7+
type EmptyFlow = Flow<unknown>;
8+
type FlowOutput = ExtractFlowOutput<EmptyFlow>;
9+
10+
// For an empty flow, output is an empty object
11+
expectTypeOf<FlowOutput>().toEqualTypeOf<Record<string, never>>();
12+
expectTypeOf<keyof FlowOutput>().toEqualTypeOf<never>();
13+
});
14+
15+
it('should correctly output for a flow with a single leaf step', () => {
16+
const singleStepFlow = new Flow<{ input: string }>({
17+
slug: 'single_step_flow',
18+
}).step({ slug: 'process' }, (input) => ({
19+
result: input.run.input.toUpperCase(),
20+
}));
21+
22+
type FlowOutput = ExtractFlowOutput<typeof singleStepFlow>;
23+
24+
// The only leaf step is "process", whose output type is StepOutput<typeof singleStepFlow, 'process'>
25+
expectTypeOf<FlowOutput>().toMatchTypeOf<{
26+
process: StepOutput<typeof singleStepFlow, 'process'>;
27+
}>();
28+
29+
// The value for process in output should match its StepOutput type
30+
expectTypeOf<FlowOutput['process']>().toEqualTypeOf<{ result: string }>();
31+
32+
// FlowOutput should not have other keys
33+
expectTypeOf<FlowOutput>().not.toMatchTypeOf<{
34+
nonExistentStep: unknown;
35+
}>();
36+
});
37+
38+
it('should correctly output for multiple leaf steps', () => {
39+
const multiLeafFlow = new Flow<{ data: number }>({
40+
slug: 'multi_leaf_flow',
41+
})
42+
.step({ slug: 'intermediate' }, (input) => ({
43+
value: input.run.data * 2,
44+
}))
45+
.step({ slug: 'leaf1', dependsOn: ['intermediate'] }, (input) => ({
46+
squared: input.intermediate.value ** 2,
47+
}))
48+
.step({ slug: 'leaf2', dependsOn: ['intermediate'] }, (input) => ({
49+
doubled: input.intermediate.value * 2,
50+
}));
51+
52+
type FlowOutput = ExtractFlowOutput<typeof multiLeafFlow>;
53+
54+
// FlowOutput should have exactly leaf1 and leaf2, each with their respective StepOutputs
55+
expectTypeOf<FlowOutput>().toMatchTypeOf<{
56+
leaf1: StepOutput<typeof multiLeafFlow, 'leaf1'>;
57+
leaf2: StepOutput<typeof multiLeafFlow, 'leaf2'>;
58+
}>();
59+
60+
expectTypeOf<FlowOutput['leaf1']>().toEqualTypeOf<{ squared: number }>();
61+
expectTypeOf<FlowOutput['leaf2']>().toEqualTypeOf<{ doubled: number }>();
62+
63+
// Should NOT have intermediate
64+
expectTypeOf<FlowOutput>().not.toMatchTypeOf<{
65+
intermediate: unknown;
66+
}>();
67+
});
68+
69+
it('should correctly handle a root step that is also a leaf step', () => {
70+
const rootLeafFlow = new Flow<{ input: string }>({ slug: 'root_leaf_flow' })
71+
.step({ slug: 'rootLeaf' }, (input) => ({
72+
processed: input.run.input.trim(),
73+
}))
74+
.step({ slug: 'intermediate' }, (input) => ({
75+
length: input.run.input.length,
76+
}))
77+
.step({ slug: 'dependent', dependsOn: ['intermediate'] }, (input) => ({
78+
result: input.intermediate.length > 10,
79+
}));
80+
81+
type FlowOutput = ExtractFlowOutput<typeof rootLeafFlow>;
82+
83+
expectTypeOf<FlowOutput>().toMatchTypeOf<{
84+
rootLeaf: StepOutput<typeof rootLeafFlow, 'rootLeaf'>;
85+
dependent: StepOutput<typeof rootLeafFlow, 'dependent'>;
86+
}>();
87+
88+
expectTypeOf<FlowOutput['rootLeaf']>().toEqualTypeOf<{
89+
processed: string;
90+
}>();
91+
expectTypeOf<FlowOutput['dependent']>().toEqualTypeOf<{
92+
result: boolean;
93+
}>();
94+
95+
expectTypeOf<FlowOutput>().not.toMatchTypeOf<{
96+
intermediate: unknown;
97+
}>();
98+
});
99+
100+
it('should handle complex dependency chains', () => {
101+
const complexFlow = new Flow<{ input: number }>({ slug: 'complex_flow' })
102+
.step({ slug: 'step1' }, (input) => ({ value: input.run.input + 1 }))
103+
.step({ slug: 'step2', dependsOn: ['step1'] }, (input) => ({
104+
value: input.step1.value * 2,
105+
}))
106+
.step({ slug: 'step3', dependsOn: ['step1'] }, (input) => ({
107+
value: input.step1.value - 1,
108+
}))
109+
.step({ slug: 'step4', dependsOn: ['step2', 'step3'] }, (input) => ({
110+
sum: input.step2.value + input.step3.value,
111+
original: input.run.input,
112+
}));
113+
114+
type FlowOutput = ExtractFlowOutput<typeof complexFlow>;
115+
116+
expectTypeOf<FlowOutput>().toMatchTypeOf<{
117+
step4: StepOutput<typeof complexFlow, 'step4'>;
118+
}>();
119+
120+
expectTypeOf<FlowOutput['step4']>().toEqualTypeOf<{
121+
sum: number;
122+
original: number;
123+
}>();
124+
125+
// No extras
126+
expectTypeOf<FlowOutput>().not.toMatchTypeOf<{
127+
step1: unknown;
128+
step2: unknown;
129+
step3: unknown;
130+
}>();
131+
});
132+
});

pkgs/dsl/src/dsl.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,22 @@ export type ExtractFlowInput<TFlow extends AnyFlow> = TFlow extends Flow<
6565
? TI
6666
: never;
6767

68+
/**
69+
* Extracts the output type from a Flow
70+
* @template TFlow - The Flow type to extract from
71+
*/
72+
export type ExtractFlowOutput<TFlow extends AnyFlow> = TFlow extends Flow<
73+
infer _TI,
74+
infer _TS,
75+
infer _TD
76+
>
77+
? {
78+
[K in keyof ExtractFlowLeafSteps<TFlow> as K extends string
79+
? K
80+
: never]: StepOutput<TFlow, K & string>;
81+
}
82+
: never;
83+
6884
/**
6985
* Extracts the steps type from a Flow
7086
* @template TFlow - The Flow type to extract from
@@ -100,6 +116,18 @@ type StepDepsOf<
100116
? ExtractFlowDeps<TFlow>[TStepSlug][number] // The string slugs that TStepSlug depends on
101117
: never;
102118

119+
/**
120+
* Extracts only the leaf steps from a Flow (steps that are not dependencies of any other steps)
121+
* @template TFlow - The Flow type to extract from
122+
*/
123+
export type ExtractFlowLeafSteps<TFlow extends AnyFlow> = {
124+
[K in keyof ExtractFlowSteps<TFlow> as K extends string
125+
? K extends ExtractFlowDeps<TFlow>[keyof ExtractFlowDeps<TFlow>][number]
126+
? never
127+
: K
128+
: never]: ExtractFlowSteps<TFlow>[K];
129+
};
130+
103131
// Utility type to extract the output type of a step handler from a Flow
104132
// Usage:
105133
// StepOutput<typeof flow, 'step1'>

0 commit comments

Comments
 (0)