Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions pkgs/client/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@
"parallel": false
}
},
"test": {
"test:vitest": {
"executor": "nx:run-commands",
"local": true,
"dependsOn": ["db:ensure", "build"],
Expand All @@ -169,6 +169,10 @@
"parallel": false
}
},
"test": {
"executor": "nx:noop",
"dependsOn": ["test:vitest", "test:types"]
},
"benchmark": {
"executor": "nx:run-commands",
"local": true,
Expand All @@ -183,7 +187,7 @@
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"command": "tsc --project tsconfig.typecheck.json --noEmit"
"command": "bash ../../scripts/typecheck-strict.sh"
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions pkgs/core/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@
},
"test": {
"executor": "nx:noop",
"dependsOn": ["test:pgtap", "test:vitest"]
"dependsOn": ["test:pgtap", "test:vitest", "test:types"]
},
"test:pgtap": {
"executor": "nx:run-commands",
Expand Down Expand Up @@ -267,7 +267,7 @@
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"command": "tsc --project tsconfig.typecheck.json --noEmit"
"command": "bash ../../scripts/typecheck-strict.sh"
}
}
}
Expand Down
58 changes: 9 additions & 49 deletions pkgs/dsl/__tests__/types/map-method.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ describe('.map() method type constraints', () => {
});

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

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

Expand Down Expand Up @@ -168,53 +168,22 @@ describe('.map() method type constraints', () => {
: never;
expectTypeOf<SumOutput>().toEqualTypeOf<number>();
});

it('should allow array step to provide input for map', () => {
const flow = new Flow<Record<string, never>>({ slug: 'test' })
.array({ slug: 'generate' }, () => ['a', 'b', 'c'])
.map({ slug: 'process', array: 'generate' }, (letter) => {
expectTypeOf(letter).toEqualTypeOf<string>();
return { letter, index: letter.charCodeAt(0) };
});

type ProcessOutput = typeof flow extends Flow<any, any, infer Steps, any>
? Steps['process']
: never;
expectTypeOf<ProcessOutput>().toEqualTypeOf<{ letter: string; index: number }[]>();
});
});

describe('context inference', () => {
it('should preserve context through map methods', () => {
const flow = new Flow<string[]>({ slug: 'test' })
.map({ slug: 'process' }, (item, context: { api: { transform: (s: string) => string } }) => {
expectTypeOf(context.api.transform).toEqualTypeOf<(s: string) => string>();
.map({ slug: 'process' }, (item, context) => {
// Let TypeScript infer the full context type
expectTypeOf(context.env).toEqualTypeOf<Record<string, string | undefined>>();
expectTypeOf(context.shutdownSignal).toEqualTypeOf<AbortSignal>();
return context.api.transform(item);
return String(item);
});

type FlowContext = ExtractFlowContext<typeof flow>;
expectTypeOf<FlowContext>().toMatchTypeOf<{
env: Record<string, string | undefined>;
shutdownSignal: AbortSignal;
api: { transform: (s: string) => string };
}>();
});

it('should accumulate context across map and regular steps', () => {
const flow = new Flow<number[]>({ slug: 'test' })
.map({ slug: 'transform' }, (n, context: { multiplier: number }) => n * context.multiplier)
.step({ slug: 'aggregate' }, (input, context: { formatter: (n: number) => string }) =>
context.formatter(input.transform.reduce((a, b) => a + b, 0))
);

type FlowContext = ExtractFlowContext<typeof flow>;
expectTypeOf<FlowContext>().toMatchTypeOf<{
env: Record<string, string | undefined>;
shutdownSignal: AbortSignal;
multiplier: number;
formatter: (n: number) => string;
}>();
});
});
Expand Down Expand Up @@ -252,25 +221,16 @@ describe('.map() method type constraints', () => {
expectTypeOf(squareStep.handler).toBeFunction();

const sumStep = flow.getStepDefinition('sum');
expectTypeOf(sumStep.handler).parameters.toMatchTypeOf<[{
// Handler should be typed to receive input and context
expectTypeOf(sumStep.handler).toBeFunction();
expectTypeOf(sumStep.handler).parameter(0).toEqualTypeOf<{
run: number[];
square: number[];
}]>();
}>();
});
});

describe('edge cases', () => {
it('should handle empty arrays', () => {
const flow = new Flow<Json[]>({ slug: 'test' })
.map({ slug: 'process' }, (item) => ({ processed: item }));

// Should be able to handle empty array input
type ProcessOutput = typeof flow extends Flow<any, any, infer Steps, any>
? Steps['process']
: never;
expectTypeOf<ProcessOutput>().toEqualTypeOf<{ processed: Json }[]>();
});

it('should handle union types in arrays', () => {
const flow = new Flow<(string | number)[]>({ slug: 'test' })
.map({ slug: 'stringify' }, (item) => {
Expand Down
164 changes: 164 additions & 0 deletions pkgs/dsl/__tests__/types/map-return-type-inference.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { Flow } from '../../src/index.js';
import { describe, it, expectTypeOf } from 'vitest';

describe('map step return type inference bug', () => {
it('should preserve specific return type from map handler, not collapse to any[]', () => {
const flow = new Flow<{ items: string[] }>({ slug: 'test' })
.array({ slug: 'chunks' }, async ({ run }) => {
return [{ data: 'chunk1' }, { data: 'chunk2' }];
})
.map(
{ slug: 'processChunks', array: 'chunks' },
async (chunk) => {
return {
chunkIndex: 0,
successes: ['success1'],
errors: [{ line: 1, error: 'test error' }], // Non-empty array for inference
};
}
)
.step(
{ slug: 'aggregate', dependsOn: ['processChunks'] },
async ({ run, processChunks }) => {
// Verify types are inferred correctly
expectTypeOf(processChunks).not.toEqualTypeOf<any[]>();

// These should all have proper types, not any
for (const result of processChunks) {
expectTypeOf(result.chunkIndex).toEqualTypeOf<number>();
expectTypeOf(result.chunkIndex).not.toEqualTypeOf<any>();
expectTypeOf(result.successes).toEqualTypeOf<string[]>();
expectTypeOf(result.successes).not.toEqualTypeOf<any>();
expectTypeOf(result.errors).toMatchTypeOf<Array<{ line: number; error: string }>>();
expectTypeOf(result.errors).not.toEqualTypeOf<any>();
}

return { done: true };
}
);

// Verify the map step output type is not any[]
type ProcessChunksOutput = typeof flow extends Flow<any, any, infer Steps, any>
? Steps['processChunks']
: never;

expectTypeOf<ProcessChunksOutput>().not.toEqualTypeOf<any[]>();
});

it('should preserve complex nested types through map', () => {
// Note: optional properties not in the return object are not inferred by TypeScript
type ComplexResult = {
nested: { deep: { value: string } };
array: number[];
};

const flow = new Flow<Record<string, never>>({ slug: 'test' })
.array({ slug: 'items' }, () => [1, 2, 3])
.map({ slug: 'transform', array: 'items' }, async (item) => {
return {
nested: { deep: { value: 'test' } },
array: [1, 2, 3]
};
})
.step({ slug: 'use', dependsOn: ['transform'] }, ({ transform }) => {
expectTypeOf(transform).toEqualTypeOf<ComplexResult[]>();
expectTypeOf(transform).not.toEqualTypeOf<any[]>();

// Verify nested structure is preserved
expectTypeOf(transform[0].nested.deep.value).toEqualTypeOf<string>();
expectTypeOf(transform[0].nested.deep.value).not.toEqualTypeOf<any>();
expectTypeOf(transform[0].array).toEqualTypeOf<number[]>();
expectTypeOf(transform[0].array).not.toEqualTypeOf<any>();

return { ok: true };
});

type TransformOutput = typeof flow extends Flow<any, any, infer Steps, any>
? Steps['transform']
: never;

expectTypeOf<TransformOutput>().toEqualTypeOf<ComplexResult[]>();
expectTypeOf<TransformOutput>().not.toEqualTypeOf<any[]>();
});

it('should preserve union-like return types from map', () => {
// Test that return types with discriminated union pattern are inferred correctly
const flow = new Flow<number[]>({ slug: 'test' })
.map({ slug: 'process' }, async (item) => {
// Return explicit objects to help TypeScript inference
const success = { success: true as const, data: 'ok' };
const failure = { success: false as const, error: 'fail' };
return Math.random() > 0.5 ? success : failure;
})
.step({ slug: 'aggregate', dependsOn: ['process'] }, ({ process }) => {
expectTypeOf(process).not.toEqualTypeOf<any[]>();

// Verify the inferred type preserves the shape
const firstResult = process[0];
expectTypeOf(firstResult.success).toEqualTypeOf<boolean>();

return { done: true };
});

type ProcessOutput = typeof flow extends Flow<any, any, infer Steps, any>
? Steps['process']
: never;

expectTypeOf<ProcessOutput>().not.toEqualTypeOf<any[]>();
});

it('should work with inferred return types (no explicit Promise type)', () => {
const flow = new Flow<string[]>({ slug: 'test' })
.map({ slug: 'transform' }, (item) => {
return { value: item.toUpperCase(), length: item.length };
})
.step({ slug: 'use', dependsOn: ['transform'] }, ({ transform }) => {
// Should infer { value: string; length: number }[]
expectTypeOf(transform).toEqualTypeOf<{ value: string; length: number }[]>();
expectTypeOf(transform).not.toEqualTypeOf<any[]>();

for (const result of transform) {
expectTypeOf(result.value).toEqualTypeOf<string>();
expectTypeOf(result.value).not.toEqualTypeOf<any>();
expectTypeOf(result.length).toEqualTypeOf<number>();
expectTypeOf(result.length).not.toEqualTypeOf<any>();
}

return { ok: true };
});

type TransformOutput = typeof flow extends Flow<any, any, infer Steps, any>
? Steps['transform']
: never;

expectTypeOf<TransformOutput>().toEqualTypeOf<{ value: string; length: number }[]>();
expectTypeOf<TransformOutput>().not.toEqualTypeOf<any[]>();
});

it('should work with root map (no array dependency)', () => {
const flow = new Flow<string[]>({ slug: 'test' })
.map({ slug: 'uppercase' }, (item) => {
return { original: item, transformed: item.toUpperCase() };
})
.step({ slug: 'aggregate', dependsOn: ['uppercase'] }, ({ uppercase }) => {
expectTypeOf(uppercase).toEqualTypeOf<{ original: string; transformed: string }[]>();
expectTypeOf(uppercase).not.toEqualTypeOf<any[]>();

for (const result of uppercase) {
expectTypeOf(result.original).toEqualTypeOf<string>();
expectTypeOf(result.original).not.toEqualTypeOf<any>();
expectTypeOf(result.transformed).toEqualTypeOf<string>();
expectTypeOf(result.transformed).not.toEqualTypeOf<any>();
}

return { count: uppercase.length };
});

type UppercaseOutput = typeof flow extends Flow<any, any, infer Steps, any>
? Steps['uppercase']
: never;

expectTypeOf<UppercaseOutput>().toEqualTypeOf<{ original: string; transformed: string }[]>();
expectTypeOf<UppercaseOutput>().not.toEqualTypeOf<any[]>();
});
});
11 changes: 9 additions & 2 deletions pkgs/dsl/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"parallel": false
}
},
"test": {
"test:vitest": {
"executor": "@nx/vite:test",
"dependsOn": ["build"],
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
Expand All @@ -38,7 +38,14 @@
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"command": "tsc --project tsconfig.typecheck.json --noEmit"
"command": "bash ../../scripts/typecheck-strict.sh"
}
},
"test": {
"executor": "nx:run-commands",
"dependsOn": ["test:vitest", "test:types"],
"options": {
"commands": []
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions pkgs/dsl/src/dsl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@ export class Flow<
): Flow<
TFlowInput,
TContext & BaseContext,
Steps & { [K in Slug]: Awaited<ReturnType<THandler & ((item: any, context: any) => any)>>[] },
Steps & { [K in Slug]: AwaitedReturn<THandler>[] },
StepDependencies & { [K in Slug]: [] }
>;

Expand All @@ -541,7 +541,7 @@ export class Flow<
): Flow<
TFlowInput,
TContext & BaseContext,
Steps & { [K in Slug]: Awaited<ReturnType<THandler & ((item: any, context: any) => any)>>[] },
Steps & { [K in Slug]: AwaitedReturn<THandler>[] },
StepDependencies & { [K in Slug]: [TArrayDep] }
>;

Expand Down
7 changes: 2 additions & 5 deletions pkgs/edge-worker/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -165,13 +165,13 @@
}
},
"test": {
"dependsOn": ["test:types:all", "test:unit", "test:integration"]
"dependsOn": ["test:types", "test:unit", "test:integration"]
},
"test:types:tsc": {
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"command": "tsc --project tsconfig.typecheck.json --noEmit"
"command": "bash ../../scripts/typecheck-strict.sh"
}
},
"test:types:examples": {
Expand All @@ -184,9 +184,6 @@
},
"test:types": {
"dependsOn": ["test:types:tsc", "test:types:examples"]
},
"test:types:all": {
"dependsOn": ["test:types"]
}
},
"tags": []
Expand Down
Loading
Loading