From 4dacc1d3079cf8def74a11b6a753144c188d0ad7 Mon Sep 17 00:00:00 2001 From: larabr <7375870+larabr@users.noreply.github.com> Date: Wed, 29 Oct 2025 18:00:08 +0100 Subject: [PATCH 1/5] TS: add `transform` and `transformAsync` type definitions --- lib/types/index.d.ts | 14 ++++++++++++++ lib/types/v4.7-v5.4/index.d.ts | 14 ++++++++++++++ test/typescript.test.ts | 29 ++++++++++++++++++++++++++++- 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/lib/types/index.d.ts b/lib/types/index.d.ts index 2de1d20..e209f25 100644 --- a/lib/types/index.d.ts +++ b/lib/types/index.d.ts @@ -25,3 +25,17 @@ export function slice>( begin: number, end?: number | typeof Infinity ): InputType; // same as 'typeof input' + +export function transform, OutData extends Data = InData>( + input: InputType, + process?: (chunk: InData) => OutData | void, + finish?: () => OutData | void, + queuingStrategy?: { highWaterMark: number } +): InputType extends InData ? OutData : Stream; + +export function transformAsync>( + input: InputType, + process?: (chunk: InData) => Promise | Promise, + finish?: (chunk: InData) => Promise | Promise, + queuingStrategy?: { highWaterMark: number } +): InputType extends InData ? OutData : Stream; diff --git a/lib/types/v4.7-v5.4/index.d.ts b/lib/types/v4.7-v5.4/index.d.ts index 0ee8066..4615657 100644 --- a/lib/types/v4.7-v5.4/index.d.ts +++ b/lib/types/v4.7-v5.4/index.d.ts @@ -27,3 +27,17 @@ export function slice>( begin: number, end?: number | typeof Infinity ): InputType; // same as 'typeof input' + +export function transform, OutData extends Data = InData>( + input: InputType, + process?: (chunk: InData) => OutData | void, + finish?: () => OutData | void, + queuingStrategy?: { highWaterMark: number } +): InputType extends InData ? OutData : Stream; + +export function transformAsync, OutData extends Data = InData>( + input: InputType, + process?: (chunk: InData) => Promise | Promise, + finish?: (chunk: InData) => Promise | Promise, + queuingStrategy?: { highWaterMark: number } +): InputType extends InData ? OutData : Stream; diff --git a/test/typescript.test.ts b/test/typescript.test.ts index 252ddb4..081a056 100644 --- a/test/typescript.test.ts +++ b/test/typescript.test.ts @@ -3,7 +3,7 @@ import assert from 'assert'; import { Readable as NodeNativeReadableStream } from 'stream'; import { ReadableStream as NodeWebReadableStream } from 'node:stream/web'; import { ReadableStream as PonyfilledWebReadableStream } from 'web-streams-polyfill'; -import { type WebStream, type NodeWebStream, type Stream, toStream, type Data } from '@openpgp/web-stream-tools'; +import { type WebStream, type NodeWebStream, type Stream, toStream, type Data, transform, transformAsync } from '@openpgp/web-stream-tools'; import { readToEnd } from '@openpgp/web-stream-tools'; // @ts-expect-error missing defs import { ArrayStream, isArrayStream } from '@openpgp/web-stream-tools'; @@ -46,6 +46,33 @@ const newEmptyWebStream = (): WebStream => ( assert(isArrayStream(new ArrayStream())) ; // ensure Array is actually extended in e.g. es5 + const transformDefaultOutputStreamString: Stream = transform(newEmptyWebStream()); + assert(transformDefaultOutputStreamString instanceof NodeWebReadableStream); + const transformDefaultOutputString: string = transform('string'); + assert(typeof transformDefaultOutputString === 'string'); + const transformProcessOutputStreamBytes: Stream> = transform( + newEmptyWebStream(), + () => new Uint8Array() + ); + assert(transformProcessOutputStreamBytes instanceof NodeWebReadableStream); + transform( + newEmptyWebStream(), + () => new Uint8Array(), + // @ts-expect-error `finish()` and `process()` output types must match + () => '' + ); + transform( + newEmptyWebStream(), + // @ts-expect-error on async callback + async () => new Uint8Array(), + ); + transformAsync( + newEmptyWebStream(), + async () => new Uint8Array(), + // @ts-expect-error on sync callback + () => new Uint8Array() + ); + console.log('TypeScript definitions are correct'); })().catch(e => { console.error('TypeScript definitions tests failed by throwing the following error'); From 97daaa9d193b834d37669db8d27ab149b06e2e21 Mon Sep 17 00:00:00 2001 From: larabr <7375870+larabr@users.noreply.github.com> Date: Thu, 6 Nov 2025 14:58:14 +0100 Subject: [PATCH 2/5] TS: `transform`, `transformAsync`: Switch to function overload instead of parametric inference With standalone declaration we cannot properly model the fact that `process` and `finish` are never undefined due to the default values set in JS. --- lib/types/index.d.ts | 40 +++++++++++++++++++++++++++++----- lib/types/v4.7-v5.4/index.d.ts | 38 +++++++++++++++++++++++++++----- test/typescript.test.ts | 26 ++++++++++++++++------ 3 files changed, 86 insertions(+), 18 deletions(-) diff --git a/lib/types/index.d.ts b/lib/types/index.d.ts index e209f25..cbb142c 100644 --- a/lib/types/index.d.ts +++ b/lib/types/index.d.ts @@ -26,16 +26,44 @@ export function slice>( end?: number | typeof Infinity ): InputType; // same as 'typeof input' +type EmptyStream = DomReadableStream | NodeWebReadableStream; + +// We use function overload instead of type parameter inference because the latter cannot capture the +// default values set in JS for `process` and `finish`, which overcomplicates things export function transform, OutData extends Data = InData>( input: InputType, - process?: (chunk: InData) => OutData | void, - finish?: () => OutData | void, + process: (chunk: InData) => OutData, + finish?: () => OutData | undefined, queuingStrategy?: { highWaterMark: number } ): InputType extends InData ? OutData : Stream; - -export function transformAsync>( +export function transform, OutData extends Data = InData>( input: InputType, - process?: (chunk: InData) => Promise | Promise, - finish?: (chunk: InData) => Promise | Promise, + process: undefined | ((chunk: InData) => undefined), + finish: () => OutData, queuingStrategy?: { highWaterMark: number } ): InputType extends InData ? OutData : Stream; +export function transform, _OutData extends Data = InData>( + input: InputType, + process?: (chunk: InData) => undefined, + finish?: () => undefined, + queuingStrategy?: { highWaterMark: number } +): InputType extends InData ? undefined : EmptyStream; + +export function transformAsync, OutData extends Data = InData>( + input: InputType, + process: (chunk: InData) => Promise, + finish?: () => Promise | Promise, + queuingStrategy?: { highWaterMark: number } +): Promise>; +export function transformAsync, OutData extends Data = InData>( + input: InputType, + process: undefined | ((chunk: InData) => Promise), + finish: () => Promise, + queuingStrategy?: { highWaterMark: number } +): Promise>; +export function transformAsync, _OutData extends Data = InData>( + input: InputType, + process?: (chunk: InData) => Promise, + finish?: () => Promise, + queuingStrategy?: { highWaterMark: number } +): Promise; diff --git a/lib/types/v4.7-v5.4/index.d.ts b/lib/types/v4.7-v5.4/index.d.ts index 4615657..115f3cc 100644 --- a/lib/types/v4.7-v5.4/index.d.ts +++ b/lib/types/v4.7-v5.4/index.d.ts @@ -28,16 +28,44 @@ export function slice>( end?: number | typeof Infinity ): InputType; // same as 'typeof input' +type EmptyStream = DomReadableStream | NodeWebReadableStream; + +// We use function overload instead of type parameter inference because the latter cannot capture the +// default values set in JS for `process` and `finish`, which overcomplicates things export function transform, OutData extends Data = InData>( input: InputType, - process?: (chunk: InData) => OutData | void, - finish?: () => OutData | void, + process: (chunk: InData) => OutData, + finish?: () => OutData | undefined, queuingStrategy?: { highWaterMark: number } ): InputType extends InData ? OutData : Stream; +export function transform, OutData extends Data = InData>( + input: InputType, + process: undefined | ((chunk: InData) => undefined), + finish: () => OutData, + queuingStrategy?: { highWaterMark: number } +): InputType extends InData ? OutData : Stream; +export function transform, _OutData extends Data = InData>( + input: InputType, + process?: (chunk: InData) => undefined, + finish?: () => undefined, + queuingStrategy?: { highWaterMark: number } +): InputType extends InData ? undefined : EmptyStream; export function transformAsync, OutData extends Data = InData>( input: InputType, - process?: (chunk: InData) => Promise | Promise, - finish?: (chunk: InData) => Promise | Promise, + process: (chunk: InData) => Promise, + finish?: () => Promise | Promise, queuingStrategy?: { highWaterMark: number } -): InputType extends InData ? OutData : Stream; +): InputType extends InData ? Promise : Stream; +export function transformAsync, OutData extends Data = InData>( + input: InputType, + process: undefined | ((chunk: InData) => Promise), + finish: () => Promise, + queuingStrategy?: { highWaterMark: number } +): InputType extends InData ? Promise : Stream; +export function transformAsync, _OutData extends Data = InData>( + input: InputType, + process?: (chunk: InData) => Promise, + finish?: () => Promise, + queuingStrategy?: { highWaterMark: number } +): InputType extends InData ? Promise : EmptyStream; diff --git a/test/typescript.test.ts b/test/typescript.test.ts index 081a056..7bf4ac7 100644 --- a/test/typescript.test.ts +++ b/test/typescript.test.ts @@ -46,30 +46,42 @@ const newEmptyWebStream = (): WebStream => ( assert(isArrayStream(new ArrayStream())) ; // ensure Array is actually extended in e.g. es5 - const transformDefaultOutputStreamString: Stream = transform(newEmptyWebStream()); - assert(transformDefaultOutputStreamString instanceof NodeWebReadableStream); - const transformDefaultOutputString: string = transform('string'); - assert(typeof transformDefaultOutputString === 'string'); + const transformDefaultOutput: undefined = transform('string'); + assert(transformDefaultOutput === undefined); + const transformOutputStreamString: Stream = transform(newEmptyWebStream(), () => ''); + assert(transformOutputStreamString instanceof NodeWebReadableStream); + const transformProcessOutputString: string = transform('string', () => ''); + assert(typeof transformProcessOutputString === 'string'); + const transformConcatOutputString: string = transform('string', () => '', () => ''); + assert(typeof transformConcatOutputString === 'string'); + const transformFinishOutputString: string = transform('string', undefined, () => ''); + assert(typeof transformFinishOutputString === 'string'); const transformProcessOutputStreamBytes: Stream> = transform( newEmptyWebStream(), () => new Uint8Array() ); assert(transformProcessOutputStreamBytes instanceof NodeWebReadableStream); + // @ts-expect-error `finish()` and `process()` output types must match transform( newEmptyWebStream(), () => new Uint8Array(), - // @ts-expect-error `finish()` and `process()` output types must match () => '' ); + // @ts-expect-error on async callback transform( newEmptyWebStream(), - // @ts-expect-error on async callback + async () => 'string', + ); + const transformAsyncOutputStreamStringToBytes: Stream = await transformAsync( + newEmptyWebStream(), async () => new Uint8Array(), + async () => new Uint8Array() ); + assert(transformAsyncOutputStreamStringToBytes instanceof NodeWebReadableStream); + // @ts-expect-error on sync callback transformAsync( newEmptyWebStream(), async () => new Uint8Array(), - // @ts-expect-error on sync callback () => new Uint8Array() ); From d98aba19b38d6412e42395634fa589b5ed411767 Mon Sep 17 00:00:00 2001 From: larabr <7375870+larabr@users.noreply.github.com> Date: Thu, 6 Nov 2025 18:09:38 +0100 Subject: [PATCH 3/5] CI: add tests for legacy type definitions Cover minimum supported TS version. --- .github/workflows/tests.yml | 2 + package.json | 1 + test/tsconfig.v4.7.json | 7 +++ test/typescript.v4.7.test.ts | 89 ++++++++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+) create mode 100644 test/tsconfig.v4.7.json create mode 100644 test/typescript.v4.7.test.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e8e8922..a30726d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -54,6 +54,8 @@ jobs: - uses: actions/setup-node@v6 - run: npm ci - run: npm run test-type-definitions + - run: npm i typescript@4.7 @types/node@18.0.0 + - run: npm run test-type-definitions-legacy lint: name: ESLint diff --git a/package.json b/package.json index 74a22ff..b33bea7 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "scripts": { "test-type-definitions": "tsc --project test/tsconfig.json && tsx test/typescript.test.ts && npm run test-type-definitions-es5", "test-type-definitions-es5": "tsc --project test/tsconfig.es5.json && tsx --tsconfig test/tsconfig.es5.json test/typescript.test.ts", + "test-type-definitions-legacy": "tsc --project test/tsconfig.v4.7.json && tsx test/typescript.v4.7.test.ts", "test-browser": "karma start karma.conf.cjs", "test-node": "NODE_OPTIONS='--import=tsx' mocha ./test/node.test.ts ./test/common.test.ts", "lint": "eslint lib test eslint.config.js", diff --git a/test/tsconfig.v4.7.json b/test/tsconfig.v4.7.json new file mode 100644 index 0000000..18a09f2 --- /dev/null +++ b/test/tsconfig.v4.7.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, // code tested by tsx + }, + "include": ["./typescript.v4.7.test.ts"] +} diff --git a/test/typescript.v4.7.test.ts b/test/typescript.v4.7.test.ts new file mode 100644 index 0000000..e200d14 --- /dev/null +++ b/test/typescript.v4.7.test.ts @@ -0,0 +1,89 @@ +/* global process */ +/** + * Tests cases are taken from `typescript.test.ts` but do not include the WebStreamPonyfill + * since the v3.3.3 ponyfill module resolution (required with the legacy TS version) does not work properly. + */ +import assert from 'assert'; +import { Readable as NodeNativeReadableStream } from 'stream'; +import { ReadableStream as NodeWebReadableStream } from 'node:stream/web'; +import { type NodeWebStream,type Stream, toStream, transform, transformAsync, type Data, readToEnd } from '@openpgp/web-stream-tools'; +// @ts-expect-error missing defs +import { ArrayStream, isArrayStream } from '@openpgp/web-stream-tools'; + +const newEmptyTypedNodeWebStream = () => ( + new NodeWebReadableStream({ start(ctrl) { ctrl.close(); } }) as NodeWebStream +); + +(async () => { + const nodeWebStream: NodeWebStream = NodeNativeReadableStream.toWeb(new NodeNativeReadableStream()); + assert(nodeWebStream instanceof NodeWebReadableStream); + // @ts-expect-error detect node stream type mismatch + const nodeNativeStream: NodeWebStream = new NodeNativeReadableStream(); + assert(nodeNativeStream instanceof NodeNativeReadableStream); + + await readToEnd(new Uint8Array([1])) as Uint8Array; + await readToEnd(new Uint8Array([1]), _ => _) as Uint8Array[]; + // @ts-expect-error expect string type + await readToEnd(newEmptyTypedNodeWebStream()) as Uint8Array; + await readToEnd(newEmptyTypedNodeWebStream(), () => new Uint8Array()) as Uint8Array; + + const anotherNodeWebStream: NodeWebStream = toStream(nodeWebStream); + assert(anotherNodeWebStream instanceof NodeWebReadableStream); + // The following type assertion may fail or not depending on the TS and @types/node versions; + // it doesn't fail with TS v4.7 + // // @ts-expect-error expect node stream in output + // const expectedWebStreamButActualNodeStream: WebStream = toStream(nodeWebStream); + // assert(expectedWebStreamButActualNodeStream instanceof NodeWebReadableStream); + const newStringStream: Stream = toStream('chunk'); + assert(newStringStream instanceof NodeWebReadableStream); + // @ts-expect-error detect type parameter mismatch + const anotherStringStream: Stream = toStream('chunk'); + assert(anotherStringStream instanceof NodeWebReadableStream); + + assert(isArrayStream(new ArrayStream())) ; // ensure Array is actually extended in e.g. es5 + + const transformDefaultOutput: undefined = transform('string'); + assert(transformDefaultOutput === undefined); + const transformOutputStreamString: Stream = transform(newEmptyTypedNodeWebStream(), () => ''); + assert(transformOutputStreamString instanceof NodeWebReadableStream); + const transformProcessOutputString: string = transform('string', () => ''); + assert(typeof transformProcessOutputString === 'string'); + const transformConcatOutputString: string = transform('string', () => '', () => ''); + assert(typeof transformConcatOutputString === 'string'); + const transformFinishOutputString: string = transform('string', undefined, () => ''); + assert(typeof transformFinishOutputString === 'string'); + const transformProcessOutputStreamBytes: Stream = transform( + newEmptyTypedNodeWebStream(), + () => new Uint8Array() + ); + assert(transformProcessOutputStreamBytes instanceof NodeWebReadableStream); + // @ts-expect-error `finish()` and `process()` output types must match + transform( + newEmptyTypedNodeWebStream(), + () => new Uint8Array(), + () => '' + ); + // @ts-expect-error on async callback + transform( + newEmptyTypedNodeWebStream(), + async () => 'string', + ); + const transformAsyncOutputStreamStringToBytes: Stream = await transformAsync( + newEmptyTypedNodeWebStream(), + async () => new Uint8Array(), + async () => new Uint8Array() + ); + assert(transformAsyncOutputStreamStringToBytes instanceof NodeWebReadableStream); + // @ts-expect-error on sync callback + transformAsync( + newEmptyTypedNodeWebStream(), + async () => new Uint8Array(), + () => new Uint8Array() + ); + + console.log('TypeScript definitions are correct'); +})().catch(e => { + console.error('TypeScript definitions tests failed by throwing the following error'); + console.error(e); + process.exit(1); +}); From 22cc3b14fecdf95d3d6d26d14d327242f4cbf59c Mon Sep 17 00:00:00 2001 From: Daniel Huigens Date: Fri, 7 Nov 2025 12:37:40 +0100 Subject: [PATCH 4/5] TS: `transform`, `transformAsync`: Simplify type definitions --- lib/types/index.d.ts | 40 +++++--------------------------- lib/types/v4.7-v5.4/index.d.ts | 42 ++++++---------------------------- test/typescript.test.ts | 19 +++------------ test/typescript.v4.7.test.ts | 19 +++------------ 4 files changed, 19 insertions(+), 101 deletions(-) diff --git a/lib/types/index.d.ts b/lib/types/index.d.ts index cbb142c..7b7e345 100644 --- a/lib/types/index.d.ts +++ b/lib/types/index.d.ts @@ -26,44 +26,16 @@ export function slice>( end?: number | typeof Infinity ): InputType; // same as 'typeof input' -type EmptyStream = DomReadableStream | NodeWebReadableStream; - -// We use function overload instead of type parameter inference because the latter cannot capture the -// default values set in JS for `process` and `finish`, which overcomplicates things -export function transform, OutData extends Data = InData>( - input: InputType, - process: (chunk: InData) => OutData, - finish?: () => OutData | undefined, - queuingStrategy?: { highWaterMark: number } -): InputType extends InData ? OutData : Stream; -export function transform, OutData extends Data = InData>( +export function transform, OutData extends Data>( input: InputType, - process: undefined | ((chunk: InData) => undefined), - finish: () => OutData, + process?: undefined | ((chunk: InData) => OutData | undefined), + finish?: undefined | (() => OutData | undefined), queuingStrategy?: { highWaterMark: number } ): InputType extends InData ? OutData : Stream; -export function transform, _OutData extends Data = InData>( - input: InputType, - process?: (chunk: InData) => undefined, - finish?: () => undefined, - queuingStrategy?: { highWaterMark: number } -): InputType extends InData ? undefined : EmptyStream; -export function transformAsync, OutData extends Data = InData>( +export function transformAsync, OutData extends Data>( input: InputType, - process: (chunk: InData) => Promise, - finish?: () => Promise | Promise, + process?: undefined | ((chunk: InData) => Promise), + finish?: undefined | (() => Promise), queuingStrategy?: { highWaterMark: number } ): Promise>; -export function transformAsync, OutData extends Data = InData>( - input: InputType, - process: undefined | ((chunk: InData) => Promise), - finish: () => Promise, - queuingStrategy?: { highWaterMark: number } -): Promise>; -export function transformAsync, _OutData extends Data = InData>( - input: InputType, - process?: (chunk: InData) => Promise, - finish?: () => Promise, - queuingStrategy?: { highWaterMark: number } -): Promise; diff --git a/lib/types/v4.7-v5.4/index.d.ts b/lib/types/v4.7-v5.4/index.d.ts index 115f3cc..5293a45 100644 --- a/lib/types/v4.7-v5.4/index.d.ts +++ b/lib/types/v4.7-v5.4/index.d.ts @@ -28,44 +28,16 @@ export function slice>( end?: number | typeof Infinity ): InputType; // same as 'typeof input' -type EmptyStream = DomReadableStream | NodeWebReadableStream; - -// We use function overload instead of type parameter inference because the latter cannot capture the -// default values set in JS for `process` and `finish`, which overcomplicates things -export function transform, OutData extends Data = InData>( +export function transform, OutData extends Data>( input: InputType, - process: (chunk: InData) => OutData, - finish?: () => OutData | undefined, + process?: undefined | ((chunk: InData) => OutData | undefined), + finish?: undefined | (() => OutData | undefined), queuingStrategy?: { highWaterMark: number } ): InputType extends InData ? OutData : Stream; -export function transform, OutData extends Data = InData>( - input: InputType, - process: undefined | ((chunk: InData) => undefined), - finish: () => OutData, - queuingStrategy?: { highWaterMark: number } -): InputType extends InData ? OutData : Stream; -export function transform, _OutData extends Data = InData>( - input: InputType, - process?: (chunk: InData) => undefined, - finish?: () => undefined, - queuingStrategy?: { highWaterMark: number } -): InputType extends InData ? undefined : EmptyStream; -export function transformAsync, OutData extends Data = InData>( - input: InputType, - process: (chunk: InData) => Promise, - finish?: () => Promise | Promise, - queuingStrategy?: { highWaterMark: number } -): InputType extends InData ? Promise : Stream; -export function transformAsync, OutData extends Data = InData>( - input: InputType, - process: undefined | ((chunk: InData) => Promise), - finish: () => Promise, - queuingStrategy?: { highWaterMark: number } -): InputType extends InData ? Promise : Stream; -export function transformAsync, _OutData extends Data = InData>( +export function transformAsync, OutData extends Data>( input: InputType, - process?: (chunk: InData) => Promise, - finish?: () => Promise, + process?: undefined | ((chunk: InData) => Promise), + finish?: undefined | (() => Promise), queuingStrategy?: { highWaterMark: number } -): InputType extends InData ? Promise : EmptyStream; +): Promise>; diff --git a/test/typescript.test.ts b/test/typescript.test.ts index 7bf4ac7..20636cd 100644 --- a/test/typescript.test.ts +++ b/test/typescript.test.ts @@ -46,8 +46,6 @@ const newEmptyWebStream = (): WebStream => ( assert(isArrayStream(new ArrayStream())) ; // ensure Array is actually extended in e.g. es5 - const transformDefaultOutput: undefined = transform('string'); - assert(transformDefaultOutput === undefined); const transformOutputStreamString: Stream = transform(newEmptyWebStream(), () => ''); assert(transformOutputStreamString instanceof NodeWebReadableStream); const transformProcessOutputString: string = transform('string', () => ''); @@ -62,16 +60,9 @@ const newEmptyWebStream = (): WebStream => ( ); assert(transformProcessOutputStreamBytes instanceof NodeWebReadableStream); // @ts-expect-error `finish()` and `process()` output types must match - transform( - newEmptyWebStream(), - () => new Uint8Array(), - () => '' - ); + transform(newEmptyWebStream(), () => new Uint8Array(), () => ''); // @ts-expect-error on async callback - transform( - newEmptyWebStream(), - async () => 'string', - ); + transform(newEmptyWebStream(), async () => 'string'); const transformAsyncOutputStreamStringToBytes: Stream = await transformAsync( newEmptyWebStream(), async () => new Uint8Array(), @@ -79,11 +70,7 @@ const newEmptyWebStream = (): WebStream => ( ); assert(transformAsyncOutputStreamStringToBytes instanceof NodeWebReadableStream); // @ts-expect-error on sync callback - transformAsync( - newEmptyWebStream(), - async () => new Uint8Array(), - () => new Uint8Array() - ); + transformAsync(newEmptyWebStream(), async () => new Uint8Array(), () => new Uint8Array()); console.log('TypeScript definitions are correct'); })().catch(e => { diff --git a/test/typescript.v4.7.test.ts b/test/typescript.v4.7.test.ts index e200d14..a5a130f 100644 --- a/test/typescript.v4.7.test.ts +++ b/test/typescript.v4.7.test.ts @@ -42,8 +42,6 @@ const newEmptyTypedNodeWebStream = () => ( assert(isArrayStream(new ArrayStream())) ; // ensure Array is actually extended in e.g. es5 - const transformDefaultOutput: undefined = transform('string'); - assert(transformDefaultOutput === undefined); const transformOutputStreamString: Stream = transform(newEmptyTypedNodeWebStream(), () => ''); assert(transformOutputStreamString instanceof NodeWebReadableStream); const transformProcessOutputString: string = transform('string', () => ''); @@ -58,16 +56,9 @@ const newEmptyTypedNodeWebStream = () => ( ); assert(transformProcessOutputStreamBytes instanceof NodeWebReadableStream); // @ts-expect-error `finish()` and `process()` output types must match - transform( - newEmptyTypedNodeWebStream(), - () => new Uint8Array(), - () => '' - ); + transform(newEmptyTypedNodeWebStream(), () => new Uint8Array(), () => ''); // @ts-expect-error on async callback - transform( - newEmptyTypedNodeWebStream(), - async () => 'string', - ); + transform(newEmptyTypedNodeWebStream(), async () => 'string'); const transformAsyncOutputStreamStringToBytes: Stream = await transformAsync( newEmptyTypedNodeWebStream(), async () => new Uint8Array(), @@ -75,11 +66,7 @@ const newEmptyTypedNodeWebStream = () => ( ); assert(transformAsyncOutputStreamStringToBytes instanceof NodeWebReadableStream); // @ts-expect-error on sync callback - transformAsync( - newEmptyTypedNodeWebStream(), - async () => new Uint8Array(), - () => new Uint8Array() - ); + transformAsync(newEmptyTypedNodeWebStream(), async () => new Uint8Array(), () => new Uint8Array()); console.log('TypeScript definitions are correct'); })().catch(e => { From 62cb334c38ad43971996e001d1d81c787ef81687 Mon Sep 17 00:00:00 2001 From: larabr <7375870+larabr@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:52:49 +0100 Subject: [PATCH 5/5] TS: `transform`, `transformAsync`: fix handling of possibly undefined outputs A downside of this solution is that we cannot enforce output type consistency for the two callbacks since we need parametric inference of the respective function types. However, using such callbacks will now result in a union output type, that should signal the usage might be off. --- lib/types/index.d.ts | 62 +++++++++++++++++++++++++++++----- lib/types/v4.7-v5.4/index.d.ts | 54 ++++++++++++++++++++++++----- test/common.test.ts | 8 ++--- test/typescript.test.ts | 20 ++++++++--- test/typescript.v4.7.test.ts | 9 +++-- 5 files changed, 127 insertions(+), 26 deletions(-) diff --git a/lib/types/index.d.ts b/lib/types/index.d.ts index 7b7e345..8c32fe5 100644 --- a/lib/types/index.d.ts +++ b/lib/types/index.d.ts @@ -8,6 +8,8 @@ export type NodeWebStream = NodeWebReadableStream; type Stream = WebStream | NodeWebStream; type MaybeStream = T | Stream; +/** Extract the type parameter `X` given `MaybeStream` */ +type MaybeStreamDataType> = T extends Stream ? X : T; export function readToEnd any = (chunks: T[]) => T>( input: MaybeStream, @@ -26,16 +28,60 @@ export function slice>( end?: number | typeof Infinity ): InputType; // same as 'typeof input' -export function transform, OutData extends Data>( +type Defined = T extends undefined ? never : T; +/** + * This type takes care of streamed return types for stream transform functions. + * This is needed to avoid adding support for `undefined` payloads in the Stream type: + * - if the callbacks return `Data | undefined`, we cannot infer output type info. Since this is considered a rare edge case, + * this is translated into the overly-generic Stream. + * - if CallbackReturnType is `undefined`, then an empty stream is returned, and it is modeled as Stream. + */ +type TransformOutputMaybeToStream< + InputType, + CallbackReturnType extends Data | undefined | void +> = InputType extends Data ? + CallbackReturnType : + Stream; +export function transform< + InputType extends MaybeStream, + OutData extends Data, + ProcessFn extends undefined | ((chunk: MaybeStreamDataType) => OutData | undefined | void) = undefined, + FinishFn extends undefined | (() => OutData | undefined | void) = undefined, +>( input: InputType, - process?: undefined | ((chunk: InData) => OutData | undefined), - finish?: undefined | (() => OutData | undefined), + process?: ProcessFn, + finish?: FinishFn, queuingStrategy?: { highWaterMark: number } -): InputType extends InData ? OutData : Stream; +): TransformOutputMaybeToStream< + InputType, + ProcessFn extends undefined ? + FinishFn extends undefined ? + undefined : + // we do not cover the case where FinishFn or ProcessFn are (undefined | function) + ReturnType> : + FinishFn extends undefined ? + ReturnType> : + (ReturnType> | ReturnType>) +>; -export function transformAsync, OutData extends Data>( +export function transformAsync< + InputType extends MaybeStream, + OutData extends Data, + ProcessFn extends undefined | ((chunk: MaybeStreamDataType) => Promise) = undefined, + FinishFn extends undefined | (() => Promise) = undefined, +>( input: InputType, - process?: undefined | ((chunk: InData) => Promise), - finish?: undefined | (() => Promise), + process?: ProcessFn, + finish?: FinishFn, queuingStrategy?: { highWaterMark: number } -): Promise>; +): Promise> : + FinishFn extends undefined ? + ReturnType> : + (ReturnType> | ReturnType>) + >>>; diff --git a/lib/types/v4.7-v5.4/index.d.ts b/lib/types/v4.7-v5.4/index.d.ts index 5293a45..cff5dcc 100644 --- a/lib/types/v4.7-v5.4/index.d.ts +++ b/lib/types/v4.7-v5.4/index.d.ts @@ -10,6 +10,7 @@ export type NodeWebStream = NodeWebReadableStream; type Stream = WebStream | NodeWebStream; type MaybeStream = T | Stream; +type MaybeStreamDataType> = T extends Stream ? X : T; export function readToEnd any = (chunks: T[]) => T>( input: MaybeStream, @@ -28,16 +29,53 @@ export function slice>( end?: number | typeof Infinity ): InputType; // same as 'typeof input' -export function transform, OutData extends Data>( +type Defined = T extends undefined ? never : T; +type TransformOutputMaybeToStream< + InputType, + CallbackReturnType extends Data | undefined | void +> = InputType extends Data ? + CallbackReturnType : + Stream; +export function transform< + InputType extends MaybeStream, + OutData extends Data, + ProcessFn extends undefined | ((chunk: MaybeStreamDataType) => OutData | undefined | void) = undefined, + FinishFn extends undefined | (() => OutData | undefined | void) = undefined, +>( input: InputType, - process?: undefined | ((chunk: InData) => OutData | undefined), - finish?: undefined | (() => OutData | undefined), + process?: ProcessFn, + finish?: FinishFn, queuingStrategy?: { highWaterMark: number } -): InputType extends InData ? OutData : Stream; +): TransformOutputMaybeToStream< + InputType, + ProcessFn extends undefined ? + FinishFn extends undefined ? + undefined : + // we do not cover the case where FinishFn or ProcessFn are (undefined | function) + ReturnType> : + FinishFn extends undefined ? + ReturnType> : + (ReturnType> | ReturnType>) +>; -export function transformAsync, OutData extends Data>( +export function transformAsync< + InputType extends MaybeStream, + OutData extends Data, + ProcessFn extends undefined | ((chunk: MaybeStreamDataType) => Promise) = undefined, + FinishFn extends undefined | (() => Promise) = undefined, +>( input: InputType, - process?: undefined | ((chunk: InData) => Promise), - finish?: undefined | (() => Promise), + process?: ProcessFn, + finish?: FinishFn, queuingStrategy?: { highWaterMark: number } -): Promise>; +): Promise> : + FinishFn extends undefined ? + ReturnType> : + (ReturnType> | ReturnType>) + >>>; diff --git a/test/common.test.ts b/test/common.test.ts index 7406910..99ea7c3 100644 --- a/test/common.test.ts +++ b/test/common.test.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; // @ts-expect-error Missing type definitions -import { toStream, toArrayStream, readToEnd, slice, pipe, ArrayStream, transform, transformAsync } from '@openpgp/web-stream-tools'; +import { toStream, toArrayStream, readToEnd, slice, pipe, ArrayStream, transform, transformAsync, type Stream } from '@openpgp/web-stream-tools'; describe('Common integration tests', () => { it('toStream/readToEnd', async () => { @@ -42,7 +42,7 @@ describe('Common integration tests', () => { it('pipe from arraystream to arraystream', async () => { const input = 'chunk'; - const inputStream = toArrayStream(input); + const inputStream: Stream = toArrayStream(input); const outputStream = new ArrayStream(); pipe(inputStream, outputStream); expect(await readToEnd(outputStream)).to.equal('chunk'); @@ -56,7 +56,7 @@ describe('Common integration tests', () => { it('transform arraystream', async () => { const input = 'chunk'; - const streamedData = toArrayStream(input); + const streamedData: Stream = toArrayStream(input); const transformed = transform(streamedData, (str: string) => str.toUpperCase()); expect(await readToEnd(transformed)).to.equal('CHUNK'); }); @@ -76,7 +76,7 @@ describe('Common integration tests', () => { it('transformAsync arraystream', async () => { const input = 'chunk'; - const streamedData = toArrayStream(input); + const streamedData: Stream = toArrayStream(input); const transformed = await transformAsync(streamedData, async (str: string) => str.toUpperCase()); expect(await readToEnd(transformed)).to.equal('CHUNK'); }); diff --git a/test/typescript.test.ts b/test/typescript.test.ts index 20636cd..52df43f 100644 --- a/test/typescript.test.ts +++ b/test/typescript.test.ts @@ -3,8 +3,7 @@ import assert from 'assert'; import { Readable as NodeNativeReadableStream } from 'stream'; import { ReadableStream as NodeWebReadableStream } from 'node:stream/web'; import { ReadableStream as PonyfilledWebReadableStream } from 'web-streams-polyfill'; -import { type WebStream, type NodeWebStream, type Stream, toStream, type Data, transform, transformAsync } from '@openpgp/web-stream-tools'; -import { readToEnd } from '@openpgp/web-stream-tools'; +import { type WebStream, type NodeWebStream, type Stream, toStream, type Data, transform, transformAsync, type MaybeStream, readToEnd } from '@openpgp/web-stream-tools'; // @ts-expect-error missing defs import { ArrayStream, isArrayStream } from '@openpgp/web-stream-tools'; @@ -46,6 +45,12 @@ const newEmptyWebStream = (): WebStream => ( assert(isArrayStream(new ArrayStream())) ; // ensure Array is actually extended in e.g. es5 + const transformDefaultOutput: undefined = transform('string'); + assert(transformDefaultOutput === undefined); + const transformUndefinedOutput: void = transform('string', () => {}); + assert(transformUndefinedOutput === undefined); + const transformStreamUndefinedOutput: Stream = transform(newEmptyWebStream(), () => {}); + assert(transformStreamUndefinedOutput instanceof NodeWebReadableStream); const transformOutputStreamString: Stream = transform(newEmptyWebStream(), () => ''); assert(transformOutputStreamString instanceof NodeWebReadableStream); const transformProcessOutputString: string = transform('string', () => ''); @@ -59,8 +64,15 @@ const newEmptyWebStream = (): WebStream => ( () => new Uint8Array() ); assert(transformProcessOutputStreamBytes instanceof NodeWebReadableStream); - // @ts-expect-error `finish()` and `process()` output types must match - transform(newEmptyWebStream(), () => new Uint8Array(), () => ''); + const filterStream = >(stream: S, filterFn: (data: any) => boolean) => ( + transform(stream, (chunk: Data) => filterFn(chunk) ? '' : undefined) + ); + const transformUnionOutput: string | undefined = filterStream('string', () => false); + assert(transformUnionOutput === undefined); + const transformMismatchingProcessFinishOutput: Stream> = + transform(newEmptyWebStream(), () => new Uint8Array(), () => ''); + assert(transformMismatchingProcessFinishOutput instanceof NodeWebReadableStream); + // @ts-expect-error on async callback transform(newEmptyWebStream(), async () => 'string'); const transformAsyncOutputStreamStringToBytes: Stream = await transformAsync( diff --git a/test/typescript.v4.7.test.ts b/test/typescript.v4.7.test.ts index a5a130f..03126bc 100644 --- a/test/typescript.v4.7.test.ts +++ b/test/typescript.v4.7.test.ts @@ -42,6 +42,10 @@ const newEmptyTypedNodeWebStream = () => ( assert(isArrayStream(new ArrayStream())) ; // ensure Array is actually extended in e.g. es5 + const transformDefaultOutput: undefined = transform('string'); + assert(transformDefaultOutput === undefined); + const transformStreamUndefinedOutput: Stream = transform(newEmptyTypedNodeWebStream(), () => {}); + assert(transformStreamUndefinedOutput instanceof NodeWebReadableStream); const transformOutputStreamString: Stream = transform(newEmptyTypedNodeWebStream(), () => ''); assert(transformOutputStreamString instanceof NodeWebReadableStream); const transformProcessOutputString: string = transform('string', () => ''); @@ -55,8 +59,9 @@ const newEmptyTypedNodeWebStream = () => ( () => new Uint8Array() ); assert(transformProcessOutputStreamBytes instanceof NodeWebReadableStream); - // @ts-expect-error `finish()` and `process()` output types must match - transform(newEmptyTypedNodeWebStream(), () => new Uint8Array(), () => ''); + const transformMismatchingProcessFinishOutput: Stream = + transform(newEmptyTypedNodeWebStream(), () => new Uint8Array(), () => ''); + assert(transformMismatchingProcessFinishOutput instanceof NodeWebReadableStream); // @ts-expect-error on async callback transform(newEmptyTypedNodeWebStream(), async () => 'string'); const transformAsyncOutputStreamStringToBytes: Stream = await transformAsync(