From 605e312e788131d8a3067227a0abdd1a2fbfb0fe Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 14:00:29 -0700 Subject: [PATCH] Let boxable function arms ride a checked-dynamic union A union with a checked-dynamic arm (`unknown`, fetch's AbortSignal) maps to the checked-dynamic value when every other arm is one the checked-dynamic tree holds faithfully. Function arms were excluded, so `Progress | AbortSignal | undefined` was refused (SC2009). The tree boxes a function whose parameters check in and whose result converts out by reference (canBoxFuncIntoDyn), and `typeof x === "function"` narrows it back out to the typed function. Such arms now join the collapse. The corpus program checks calls, identity (`===` against the original), and the other arm's members against Node. pi's SessionManager.listAll takes `onProgressOrSignal?: SessionListProgress | AbortSignal`. The refusal rejected the whole SessionManager class. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/type-mapper.ts | 11 +++++ .../corpus/dyn-union-function-arms.ts.json | 6 +++ tests/corpus/dyn-union-function-arms.ts | 48 +++++++++++++++++++ .../dyn-union-function-arms.ts.json | 1 + 4 files changed, 66 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dyn-union-function-arms.ts.json create mode 100644 tests/corpus/dyn-union-function-arms.ts create mode 100644 tests/harness/corpus-costs/dyn-union-function-arms.ts.json diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 17f9d0d11..03d85ba7d 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -10,6 +10,7 @@ import { isDynTypedRefType, BOOL, bytesOf, + canBoxFuncIntoDyn, canConvertToDyn, canDynCheckTo, CHILD_T, @@ -8341,6 +8342,16 @@ function dynSubsumableUnionArm(arm: IrType, ctx: TypeMapperCtx): boolean { // `string | URL | Request`, fetch's own input type under @types/node. case "url": return true; + // A function the checked-dynamic tree can box (each parameter checks + // in, the result converts out) is held by reference there, and + // `typeof x === "function"` narrows it back out: `Progress | AbortSignal` + // beside fetch's handles. + case "func": + return canBoxFuncIntoDyn( + arm, + (id) => ctx.shapes.get(id), + (id) => ctx.unions.get(id), + ); case "record": case "array": // `T | Promise` with T bound to `unknown`: the checked-dynamic value diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dyn-union-function-arms.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dyn-union-function-arms.ts.json new file mode 100644 index 000000000..80675e5e6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dyn-union-function-arms.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/dyn-union-function-arms.ts" + ], + "diags": [] +} diff --git a/tests/corpus/dyn-union-function-arms.ts b/tests/corpus/dyn-union-function-arms.ts new file mode 100644 index 000000000..e85cc2e6b --- /dev/null +++ b/tests/corpus/dyn-union-function-arms.ts @@ -0,0 +1,48 @@ +// A union of a function type with a value the checked-dynamic tree holds +// (AbortSignal, `unknown`) rides that representation: the function is held +// by reference, `typeof x === "function"` narrows it back out, and calls, +// identity and the other arm's members behave as in Node. + +type Progress = (loaded: number, total: number) => void; + +function listAll( + dirOrProgress?: string | Progress, + progressOrSignal?: Progress | AbortSignal, + signal?: AbortSignal, +): string { + const dir = typeof dirOrProgress === "string" ? dirOrProgress : "/default"; + const progress = + typeof dirOrProgress === "function" + ? dirOrProgress + : typeof progressOrSignal === "function" + ? progressOrSignal + : undefined; + const abortSignal = + typeof dirOrProgress === "string" || typeof progressOrSignal === "function" + ? signal + : (progressOrSignal ?? signal); + progress?.(1, 3); + progress?.(3, 3); + return `${dir} aborted=${abortSignal?.aborted ?? "none"}`; +} + +const seen: string[] = []; +const report: Progress = (loaded, total) => seen.push(`${loaded}/${total}`); +const controller = new AbortController(); +console.log(listAll()); +console.log(listAll("/x", report)); +console.log(listAll(report, controller.signal)); +controller.abort(); +console.log(listAll("/y", undefined, controller.signal)); +console.log(listAll(report, controller.signal), seen.join(" ")); + +function same(handler: Progress | AbortSignal | undefined): boolean { + return handler === report; +} +console.log(same(report), same(controller.signal), same(undefined), same((a, b) => void (a + b))); + +function describe(value: unknown | ((n: number) => string)): string { + if (typeof value === "function") return `function -> ${(value as (n: number) => string)(7)}`; + return `value ${JSON.stringify(value)}`; +} +console.log(describe((n: number) => `n=${n}`), describe({ a: 1 }), describe("s")); diff --git a/tests/harness/corpus-costs/dyn-union-function-arms.ts.json b/tests/harness/corpus-costs/dyn-union-function-arms.ts.json new file mode 100644 index 000000000..1254aa6e4 --- /dev/null +++ b/tests/harness/corpus-costs/dyn-union-function-arms.ts.json @@ -0,0 +1 @@ +{ "plain": 4000 }