Skip to content

Commit c940873

Browse files
committed
fix: typecheck tests using strict tsc mode (#231)
### TL;DR Improved TypeScript type checking with stricter validation and fixed map return type inference in the Flow DSL. ### What changed? - Added a new `typecheck-strict.sh` script that performs two-pass type checking: 1. Project-wide type check 2. Individual file checks to catch unused `@ts-expect-error` directives - Updated project.json files to use the new script for type checking - Made `test:types` a dependency of the main `test` target in multiple packages - Fixed type inference issues in the Flow DSL's map method by using `AwaitedReturn<THandler>[]` instead of `any[]` - Added new type tests in `map-return-type-inference.test-d.ts` to verify proper type inference - Cleaned up and improved existing type tests ### How to test? 1. Run the type checking with the new script: ```bash ./scripts/typecheck-strict.sh ``` 2. Run the type tests to verify the fixed map return type inference: ```bash pnpm nx test:types dsl ``` 3. Verify that the main test command now includes type checking: ```bash pnpm nx test dsl pnpm nx test client pnpm nx test core ``` ### Why make this change? The Flow DSL had a type inference bug where map step return types were incorrectly inferred as `any[]` instead of preserving the specific return type structure. This made it difficult to catch type errors in subsequent steps that used the map output. The new strict type checking process also helps catch unused `@ts-expect-error` directives, which can hide real type errors when they're no longer needed.
1 parent 524db03 commit c940873

File tree

14 files changed

+330
-256
lines changed

14 files changed

+330
-256
lines changed

pkgs/client/project.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@
159159
"parallel": false
160160
}
161161
},
162-
"test": {
162+
"test:vitest": {
163163
"executor": "nx:run-commands",
164164
"local": true,
165165
"dependsOn": ["db:ensure", "build"],
@@ -169,6 +169,10 @@
169169
"parallel": false
170170
}
171171
},
172+
"test": {
173+
"executor": "nx:noop",
174+
"dependsOn": ["test:vitest", "test:types"]
175+
},
172176
"benchmark": {
173177
"executor": "nx:run-commands",
174178
"local": true,
@@ -183,7 +187,7 @@
183187
"executor": "nx:run-commands",
184188
"options": {
185189
"cwd": "{projectRoot}",
186-
"command": "tsc --project tsconfig.typecheck.json --noEmit"
190+
"command": "bash ../../scripts/typecheck-strict.sh"
187191
}
188192
}
189193
}

pkgs/core/project.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@
194194
},
195195
"test": {
196196
"executor": "nx:noop",
197-
"dependsOn": ["test:pgtap", "test:vitest"]
197+
"dependsOn": ["test:pgtap", "test:vitest", "test:types"]
198198
},
199199
"test:pgtap": {
200200
"executor": "nx:run-commands",
@@ -267,7 +267,7 @@
267267
"executor": "nx:run-commands",
268268
"options": {
269269
"cwd": "{projectRoot}",
270-
"command": "tsc --project tsconfig.typecheck.json --noEmit"
270+
"command": "bash ../../scripts/typecheck-strict.sh"
271271
}
272272
}
273273
}

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

Lines changed: 9 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,12 @@ describe('.map() method type constraints', () => {
1818
});
1919

2020
it('should reject root map when flow input is not array', () => {
21-
// @ts-expect-error - Flow input must be array for root map
2221
new Flow<string>({ slug: 'test' })
22+
// @ts-expect-error - Flow input must be array for root map
2323
.map({ slug: 'fail' }, (item) => item);
2424

25-
// @ts-expect-error - Object is not an array
2625
new Flow<{ name: string }>({ slug: 'test' })
26+
// @ts-expect-error - Object is not an array
2727
.map({ slug: 'fail2' }, (item) => item);
2828
});
2929

@@ -168,53 +168,22 @@ describe('.map() method type constraints', () => {
168168
: never;
169169
expectTypeOf<SumOutput>().toEqualTypeOf<number>();
170170
});
171-
172-
it('should allow array step to provide input for map', () => {
173-
const flow = new Flow<Record<string, never>>({ slug: 'test' })
174-
.array({ slug: 'generate' }, () => ['a', 'b', 'c'])
175-
.map({ slug: 'process', array: 'generate' }, (letter) => {
176-
expectTypeOf(letter).toEqualTypeOf<string>();
177-
return { letter, index: letter.charCodeAt(0) };
178-
});
179-
180-
type ProcessOutput = typeof flow extends Flow<any, any, infer Steps, any>
181-
? Steps['process']
182-
: never;
183-
expectTypeOf<ProcessOutput>().toEqualTypeOf<{ letter: string; index: number }[]>();
184-
});
185171
});
186172

187173
describe('context inference', () => {
188174
it('should preserve context through map methods', () => {
189175
const flow = new Flow<string[]>({ slug: 'test' })
190-
.map({ slug: 'process' }, (item, context: { api: { transform: (s: string) => string } }) => {
191-
expectTypeOf(context.api.transform).toEqualTypeOf<(s: string) => string>();
176+
.map({ slug: 'process' }, (item, context) => {
177+
// Let TypeScript infer the full context type
192178
expectTypeOf(context.env).toEqualTypeOf<Record<string, string | undefined>>();
193179
expectTypeOf(context.shutdownSignal).toEqualTypeOf<AbortSignal>();
194-
return context.api.transform(item);
180+
return String(item);
195181
});
196182

197183
type FlowContext = ExtractFlowContext<typeof flow>;
198184
expectTypeOf<FlowContext>().toMatchTypeOf<{
199185
env: Record<string, string | undefined>;
200186
shutdownSignal: AbortSignal;
201-
api: { transform: (s: string) => string };
202-
}>();
203-
});
204-
205-
it('should accumulate context across map and regular steps', () => {
206-
const flow = new Flow<number[]>({ slug: 'test' })
207-
.map({ slug: 'transform' }, (n, context: { multiplier: number }) => n * context.multiplier)
208-
.step({ slug: 'aggregate' }, (input, context: { formatter: (n: number) => string }) =>
209-
context.formatter(input.transform.reduce((a, b) => a + b, 0))
210-
);
211-
212-
type FlowContext = ExtractFlowContext<typeof flow>;
213-
expectTypeOf<FlowContext>().toMatchTypeOf<{
214-
env: Record<string, string | undefined>;
215-
shutdownSignal: AbortSignal;
216-
multiplier: number;
217-
formatter: (n: number) => string;
218187
}>();
219188
});
220189
});
@@ -252,25 +221,16 @@ describe('.map() method type constraints', () => {
252221
expectTypeOf(squareStep.handler).toBeFunction();
253222

254223
const sumStep = flow.getStepDefinition('sum');
255-
expectTypeOf(sumStep.handler).parameters.toMatchTypeOf<[{
224+
// Handler should be typed to receive input and context
225+
expectTypeOf(sumStep.handler).toBeFunction();
226+
expectTypeOf(sumStep.handler).parameter(0).toEqualTypeOf<{
256227
run: number[];
257228
square: number[];
258-
}]>();
229+
}>();
259230
});
260231
});
261232

262233
describe('edge cases', () => {
263-
it('should handle empty arrays', () => {
264-
const flow = new Flow<Json[]>({ slug: 'test' })
265-
.map({ slug: 'process' }, (item) => ({ processed: item }));
266-
267-
// Should be able to handle empty array input
268-
type ProcessOutput = typeof flow extends Flow<any, any, infer Steps, any>
269-
? Steps['process']
270-
: never;
271-
expectTypeOf<ProcessOutput>().toEqualTypeOf<{ processed: Json }[]>();
272-
});
273-
274234
it('should handle union types in arrays', () => {
275235
const flow = new Flow<(string | number)[]>({ slug: 'test' })
276236
.map({ slug: 'stringify' }, (item) => {
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { Flow } from '../../src/index.js';
2+
import { describe, it, expectTypeOf } from 'vitest';
3+
4+
describe('map step return type inference bug', () => {
5+
it('should preserve specific return type from map handler, not collapse to any[]', () => {
6+
const flow = new Flow<{ items: string[] }>({ slug: 'test' })
7+
.array({ slug: 'chunks' }, async ({ run }) => {
8+
return [{ data: 'chunk1' }, { data: 'chunk2' }];
9+
})
10+
.map(
11+
{ slug: 'processChunks', array: 'chunks' },
12+
async (chunk) => {
13+
return {
14+
chunkIndex: 0,
15+
successes: ['success1'],
16+
errors: [{ line: 1, error: 'test error' }], // Non-empty array for inference
17+
};
18+
}
19+
)
20+
.step(
21+
{ slug: 'aggregate', dependsOn: ['processChunks'] },
22+
async ({ run, processChunks }) => {
23+
// Verify types are inferred correctly
24+
expectTypeOf(processChunks).not.toEqualTypeOf<any[]>();
25+
26+
// These should all have proper types, not any
27+
for (const result of processChunks) {
28+
expectTypeOf(result.chunkIndex).toEqualTypeOf<number>();
29+
expectTypeOf(result.chunkIndex).not.toEqualTypeOf<any>();
30+
expectTypeOf(result.successes).toEqualTypeOf<string[]>();
31+
expectTypeOf(result.successes).not.toEqualTypeOf<any>();
32+
expectTypeOf(result.errors).toMatchTypeOf<Array<{ line: number; error: string }>>();
33+
expectTypeOf(result.errors).not.toEqualTypeOf<any>();
34+
}
35+
36+
return { done: true };
37+
}
38+
);
39+
40+
// Verify the map step output type is not any[]
41+
type ProcessChunksOutput = typeof flow extends Flow<any, any, infer Steps, any>
42+
? Steps['processChunks']
43+
: never;
44+
45+
expectTypeOf<ProcessChunksOutput>().not.toEqualTypeOf<any[]>();
46+
});
47+
48+
it('should preserve complex nested types through map', () => {
49+
// Note: optional properties not in the return object are not inferred by TypeScript
50+
type ComplexResult = {
51+
nested: { deep: { value: string } };
52+
array: number[];
53+
};
54+
55+
const flow = new Flow<Record<string, never>>({ slug: 'test' })
56+
.array({ slug: 'items' }, () => [1, 2, 3])
57+
.map({ slug: 'transform', array: 'items' }, async (item) => {
58+
return {
59+
nested: { deep: { value: 'test' } },
60+
array: [1, 2, 3]
61+
};
62+
})
63+
.step({ slug: 'use', dependsOn: ['transform'] }, ({ transform }) => {
64+
expectTypeOf(transform).toEqualTypeOf<ComplexResult[]>();
65+
expectTypeOf(transform).not.toEqualTypeOf<any[]>();
66+
67+
// Verify nested structure is preserved
68+
expectTypeOf(transform[0].nested.deep.value).toEqualTypeOf<string>();
69+
expectTypeOf(transform[0].nested.deep.value).not.toEqualTypeOf<any>();
70+
expectTypeOf(transform[0].array).toEqualTypeOf<number[]>();
71+
expectTypeOf(transform[0].array).not.toEqualTypeOf<any>();
72+
73+
return { ok: true };
74+
});
75+
76+
type TransformOutput = typeof flow extends Flow<any, any, infer Steps, any>
77+
? Steps['transform']
78+
: never;
79+
80+
expectTypeOf<TransformOutput>().toEqualTypeOf<ComplexResult[]>();
81+
expectTypeOf<TransformOutput>().not.toEqualTypeOf<any[]>();
82+
});
83+
84+
it('should preserve union-like return types from map', () => {
85+
// Test that return types with discriminated union pattern are inferred correctly
86+
const flow = new Flow<number[]>({ slug: 'test' })
87+
.map({ slug: 'process' }, async (item) => {
88+
// Return explicit objects to help TypeScript inference
89+
const success = { success: true as const, data: 'ok' };
90+
const failure = { success: false as const, error: 'fail' };
91+
return Math.random() > 0.5 ? success : failure;
92+
})
93+
.step({ slug: 'aggregate', dependsOn: ['process'] }, ({ process }) => {
94+
expectTypeOf(process).not.toEqualTypeOf<any[]>();
95+
96+
// Verify the inferred type preserves the shape
97+
const firstResult = process[0];
98+
expectTypeOf(firstResult.success).toEqualTypeOf<boolean>();
99+
100+
return { done: true };
101+
});
102+
103+
type ProcessOutput = typeof flow extends Flow<any, any, infer Steps, any>
104+
? Steps['process']
105+
: never;
106+
107+
expectTypeOf<ProcessOutput>().not.toEqualTypeOf<any[]>();
108+
});
109+
110+
it('should work with inferred return types (no explicit Promise type)', () => {
111+
const flow = new Flow<string[]>({ slug: 'test' })
112+
.map({ slug: 'transform' }, (item) => {
113+
return { value: item.toUpperCase(), length: item.length };
114+
})
115+
.step({ slug: 'use', dependsOn: ['transform'] }, ({ transform }) => {
116+
// Should infer { value: string; length: number }[]
117+
expectTypeOf(transform).toEqualTypeOf<{ value: string; length: number }[]>();
118+
expectTypeOf(transform).not.toEqualTypeOf<any[]>();
119+
120+
for (const result of transform) {
121+
expectTypeOf(result.value).toEqualTypeOf<string>();
122+
expectTypeOf(result.value).not.toEqualTypeOf<any>();
123+
expectTypeOf(result.length).toEqualTypeOf<number>();
124+
expectTypeOf(result.length).not.toEqualTypeOf<any>();
125+
}
126+
127+
return { ok: true };
128+
});
129+
130+
type TransformOutput = typeof flow extends Flow<any, any, infer Steps, any>
131+
? Steps['transform']
132+
: never;
133+
134+
expectTypeOf<TransformOutput>().toEqualTypeOf<{ value: string; length: number }[]>();
135+
expectTypeOf<TransformOutput>().not.toEqualTypeOf<any[]>();
136+
});
137+
138+
it('should work with root map (no array dependency)', () => {
139+
const flow = new Flow<string[]>({ slug: 'test' })
140+
.map({ slug: 'uppercase' }, (item) => {
141+
return { original: item, transformed: item.toUpperCase() };
142+
})
143+
.step({ slug: 'aggregate', dependsOn: ['uppercase'] }, ({ uppercase }) => {
144+
expectTypeOf(uppercase).toEqualTypeOf<{ original: string; transformed: string }[]>();
145+
expectTypeOf(uppercase).not.toEqualTypeOf<any[]>();
146+
147+
for (const result of uppercase) {
148+
expectTypeOf(result.original).toEqualTypeOf<string>();
149+
expectTypeOf(result.original).not.toEqualTypeOf<any>();
150+
expectTypeOf(result.transformed).toEqualTypeOf<string>();
151+
expectTypeOf(result.transformed).not.toEqualTypeOf<any>();
152+
}
153+
154+
return { count: uppercase.length };
155+
});
156+
157+
type UppercaseOutput = typeof flow extends Flow<any, any, infer Steps, any>
158+
? Steps['uppercase']
159+
: never;
160+
161+
expectTypeOf<UppercaseOutput>().toEqualTypeOf<{ original: string; transformed: string }[]>();
162+
expectTypeOf<UppercaseOutput>().not.toEqualTypeOf<any[]>();
163+
});
164+
});

pkgs/dsl/project.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"parallel": false
2626
}
2727
},
28-
"test": {
28+
"test:vitest": {
2929
"executor": "@nx/vite:test",
3030
"dependsOn": ["build"],
3131
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
@@ -38,8 +38,12 @@
3838
"executor": "nx:run-commands",
3939
"options": {
4040
"cwd": "{projectRoot}",
41-
"command": "tsc --project tsconfig.typecheck.json --noEmit"
41+
"command": "bash ../../scripts/typecheck-strict.sh"
4242
}
43+
},
44+
"test": {
45+
"executor": "nx:noop",
46+
"dependsOn": ["test:vitest", "test:types"]
4347
}
4448
}
4549
}

pkgs/dsl/src/dsl.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,37 @@ export type ExtractFlowContext<TFlow extends AnyFlow> = TFlow extends Flow<
178178
? FlowContext<TEnv> & TC
179179
: never;
180180

181+
/**
182+
* Type guard that ensures a flow's context requirements can be satisfied
183+
* by the resources provided by the platform and optional user resources.
184+
*
185+
* A flow is compatible if the provided platform and user resources can satisfy
186+
* all the context requirements declared by the flow.
187+
*
188+
* @template F - The Flow type to check for compatibility
189+
* @template PlatformResources - Resources provided by the execution platform (e.g., Supabase resources)
190+
* @template UserResources - Additional user-provided resources (default: empty)
191+
*
192+
* @example
193+
* ```typescript
194+
* // In a platform worker:
195+
* type SupabaseCompatibleFlow<F extends AnyFlow> = CompatibleFlow<F, SupabaseResources>;
196+
*
197+
* // Usage:
198+
* function startWorker<F extends AnyFlow>(flow: SupabaseCompatibleFlow<F>) {
199+
* // flow is guaranteed to be compatible with Supabase platform
200+
* }
201+
* ```
202+
*/
203+
export type CompatibleFlow<
204+
F extends AnyFlow,
205+
PlatformResources extends Record<string, unknown>,
206+
UserResources extends Record<string, unknown> = Record<string, never>
207+
> =
208+
(FlowContext<ExtractFlowEnv<F>> & PlatformResources & UserResources) extends ExtractFlowContext<F>
209+
? F
210+
: never;
211+
181212
/**
182213
* Extracts the dependencies type from a Flow
183214
* @template TFlow - The Flow type to extract from
@@ -528,7 +559,7 @@ export class Flow<
528559
): Flow<
529560
TFlowInput,
530561
TContext & BaseContext,
531-
Steps & { [K in Slug]: Awaited<ReturnType<THandler & ((item: any, context: any) => any)>>[] },
562+
Steps & { [K in Slug]: AwaitedReturn<THandler>[] },
532563
StepDependencies & { [K in Slug]: [] }
533564
>;
534565

@@ -541,7 +572,7 @@ export class Flow<
541572
): Flow<
542573
TFlowInput,
543574
TContext & BaseContext,
544-
Steps & { [K in Slug]: Awaited<ReturnType<THandler & ((item: any, context: any) => any)>>[] },
575+
Steps & { [K in Slug]: AwaitedReturn<THandler>[] },
545576
StepDependencies & { [K in Slug]: [TArrayDep] }
546577
>;
547578

0 commit comments

Comments
 (0)