From 72179f8df1c91396acc14c4ebc37a205a4f2ee24 Mon Sep 17 00:00:00 2001 From: blurname Date: Sat, 17 Oct 2026 14:20:15 +0800 Subject: [PATCH] FIX: compiler: preserve uninhabited arms during result widening --- .../src/frontend/lowering/union-retag.test.ts | 18 +++++ .../src/frontend/lowering/union-retag.ts | 2 +- packages/compiler/src/frontend/type-mapper.ts | 70 ++++++++++++++++--- packages/compiler/src/ir/ir.ts | 1 + packages/compiler/src/ir/validate.test.ts | 19 +++++ packages/compiler/src/ir/validate.ts | 10 +++ .../corpus/result-never-widening.ts.json | 6 ++ .../compiler/test/type-mapper-unions.test.ts | 54 ++++++++++++++ tests/corpus/result-never-widening.ts | 53 ++++++++++++++ 9 files changed, 223 insertions(+), 10 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/result-never-widening.ts.json create mode 100644 tests/corpus/result-never-widening.ts diff --git a/packages/compiler/src/frontend/lowering/union-retag.test.ts b/packages/compiler/src/frontend/lowering/union-retag.test.ts index 0cb7b1b9ff..f8c6521c50 100644 --- a/packages/compiler/src/frontend/lowering/union-retag.test.ts +++ b/packages/compiler/src/frontend/lowering/union-retag.test.ts @@ -700,3 +700,21 @@ describe("retag IR construction", () => { ).toThrow("incomplete union retag plan"); }); }); + +test("only a proven uninhabited arm can bypass an incompatible payload conversion", () => { + const from: IrUnionDef = { id: "u0", arms: [record("ok"), record("numberError")] }; + const to: IrUnionDef = { id: "u1", arms: [record("ok"), record("stringError")] }; + const plan = () => + planUnionRetag( + from, + to, + () => undefined, + () => null, + ); + expect(plan()).toBeNull(); + from.uninhabited = [1]; + expect(plan()).toEqual([ + { kind: "direct", route: { tag: 0, lift: { how: "copy" }, values: [] } }, + { kind: "trap" }, + ]); +}); diff --git a/packages/compiler/src/frontend/lowering/union-retag.ts b/packages/compiler/src/frontend/lowering/union-retag.ts index 8d1c61d093..81f121107b 100644 --- a/packages/compiler/src/frontend/lowering/union-retag.ts +++ b/packages/compiler/src/frontend/lowering/union-retag.ts @@ -138,7 +138,7 @@ export function planUnionRetag( } // A checker-proven absent arm retains any exact representation, as // before. Otherwise the narrowing path needs no structural conversion. - if (identity < 0 && trappable?.has(tag)) { + if (identity < 0 && (trappable?.has(tag) || from.uninhabited?.includes(tag))) { result.push({ kind: "trap" }); continue; } diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 320d4b2379..fe1c6af9a0 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -524,7 +524,12 @@ export class UnionRegistry { /** Completes a recursive placeholder with its canonical arm list and * registers the structural key (first writer wins, like shapes). */ - finalizeRecursive(t: ts.Type, arms: IrType[], discriminant?: IrUnionDiscriminant): string { + finalizeRecursive( + t: ts.Type, + arms: IrType[], + discriminant?: IrUnionDiscriminant, + uninhabited?: number[], + ): string { const id = this.recIds.get(t); if (id === undefined) throw new InternalCompilerError( @@ -534,22 +539,28 @@ export class UnionRegistry { const def = this.byId.get(id)!; def.arms.push(...arms); if (discriminant) def.discriminant = discriminant; + if (uninhabited) def.uninhabited = uninhabited; this.pendingRec.delete(id); this.armTags.delete(id); this.revision++; - const key = JSON.stringify([arms.map(typeKey), discriminant]); + const key = JSON.stringify([arms.map(typeKey), discriminant, uninhabited]); if (!this.byKey.has(key)) this.byKey.set(key, id); } return id; } /** Interns canonical arms and discriminator cases, returning their unionId. */ - intern(arms: IrType[], discriminant?: IrUnionDiscriminant): string { - const key = JSON.stringify([arms.map(typeKey), discriminant]); + intern(arms: IrType[], discriminant?: IrUnionDiscriminant, uninhabited?: number[]): string { + const key = JSON.stringify([arms.map(typeKey), discriminant, uninhabited]); let id = this.byKey.get(key); if (id === undefined) { id = `u${this.unions.length}`; - const def: IrUnionDef = { id, arms, ...(discriminant ? { discriminant } : {}) }; + const def: IrUnionDef = { + id, + arms, + ...(discriminant ? { discriminant } : {}), + ...(uninhabited ? { uninhabited } : {}), + }; this.byKey.set(key, id); this.byId.set(id, def); this.unions.push(def); @@ -559,7 +570,14 @@ export class UnionRegistry { /** An exact arm transformation keeps the source's semantic variants. */ transform(source: IrUnionDef, arms: IrType[]): string { - return this.intern(arms, remapUnionDiscriminant(source, arms)); + const uninhabited = arms.flatMap((arm, tag) => + source.uninhabited?.some((index) => typeEquals(source.arms[index]!, arm)) ? [tag] : [], + ); + return this.intern( + arms, + remapUnionDiscriminant(source, arms), + uninhabited.length ? uninhabited : undefined, + ); } get(unionId: string): IrUnionDef | undefined { @@ -3992,6 +4010,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { const sensitivityAtEntry = contextResolutions; try { const byKey = new Map(); + const inhabited = new Set(); const recordParts: { source: ts.Type; mapped: IrType & { kind: "record" } }[] = []; const substitutedUnions: IrUnionDef[] = []; // The lib's `T extends readonly unknown[] | []` idiom (Promise.all, @@ -4014,10 +4033,12 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // void stays VOID (the return-position mapping above). if (part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) { byKey.set(typeKey(UNDEFINED_T), UNDEFINED_T); + inhabited.add(typeKey(UNDEFINED_T)); continue; } if (part.flags & ts.TypeFlags.Null) { byKey.set(typeKey(NULL_T), NULL_T); + inhabited.add(typeKey(NULL_T)); continue; } const mapped = mapType(part, ctx); @@ -4027,6 +4048,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { } if (mapped.kind === "void") { byKey.set(typeKey(UNDEFINED_T), UNDEFINED_T); + inhabited.add(typeKey(UNDEFINED_T)); continue; } // A generic parameter can substitute a complete union into one @@ -4038,10 +4060,15 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { if (!substituted?.arms.length || substituted.arms.some((arm) => arm.kind === "union")) return null; substitutedUnions.push(substituted); - for (const arm of substituted.arms) byKey.set(typeKey(arm), arm); + substituted.arms.forEach((arm, tag) => { + byKey.set(typeKey(arm), arm); + if (!substituted.uninhabited?.includes(tag)) inhabited.add(typeKey(arm)); + }); continue; } byKey.set(typeKey(mapped), mapped); + if (mapped.kind !== "record" || !hasRequiredNeverProperty(part, ctx)) + inhabited.add(typeKey(mapped)); if (mapped.kind === "record") recordParts.push({ source: part, mapped }); } const arms = [...byKey.values()]; @@ -4096,6 +4123,8 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { return null; } arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + const impossible = arms.flatMap((arm, tag) => (inhabited.has(typeKey(arm)) ? [] : [tag])); + const uninhabited = impossible.length ? impossible : undefined; const discriminant = unionDiscriminant(recordParts, arms, ctx) ?? (substitutedUnions.length === 1 @@ -4108,9 +4137,12 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // same ts.Type answers differently per instantiation — so // recursive generic-open unions stay fenced. if (contextResolutions !== sensitivityAtEntry) return null; - return { kind: "union", unionId: unions.finalizeRecursive(widened, arms, discriminant) }; + return { + kind: "union", + unionId: unions.finalizeRecursive(widened, arms, discriminant, uninhabited), + }; } - return { kind: "union", unionId: unions.intern(arms, discriminant) }; + return { kind: "union", unionId: unions.intern(arms, discriminant, uninhabited) }; } finally { unions.inProgress.delete(widened); } @@ -4118,6 +4150,26 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { return null; } +function hasRequiredNeverProperty(type: ts.Type, ctx: TypeMapperCtx): boolean { + if (!(type.flags & (ts.TypeFlags.Object | ts.TypeFlags.Intersection))) return false; + return ctx.checker.getPropertiesOfType(type).some((property) => { + if ( + property.flags & + (ts.SymbolFlags.Optional | ts.SymbolFlags.GetAccessor | ts.SymbolFlags.SetAccessor) + ) + return false; + let value = ctx.checker.getTypeOfSymbol(property); + if (value.flags & ts.TypeFlags.TypeParameter) { + const resolved = ctx.resolveTypeParamTs?.(value); + if (resolved) { + contextResolutions++; + value = resolved; + } + } + return ctx.checker.isNeverType(value); + }); +} + /** Retain a literal discriminator before widening erases its values. A * structural match alone is insufficient: `{ kind: "loop", body: ... }` * also fits a smaller `{ kind: "empty" }` record. Choosing that layout diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 10d4d1a143..ec0806ea54 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1558,6 +1558,7 @@ export interface IrUnionDef { * unit kinds (undefinedT/nullT) are payload-less arms. */ arms: IrType[]; discriminant?: IrUnionDiscriminant; + uninhabited?: number[]; } export interface IrFunction { diff --git a/packages/compiler/src/ir/validate.test.ts b/packages/compiler/src/ir/validate.test.ts index 463b9bc626..3c5bdbc810 100644 --- a/packages/compiler/src/ir/validate.test.ts +++ b/packages/compiler/src/ir/validate.test.ts @@ -2114,3 +2114,22 @@ test("a caught-typed global records a module error only from a catch binding", ( 'in main: assign to catch binding "%error" (frontend must reject)', ]); }); + +test.each([-1, 0, 1.5, 99])("uninhabited union tags reject invalid index %s", (tag) => { + const mod = discriminatedModule(); + mod.unions![0]!.uninhabited = [tag]; + expect( + validateModule(mod).some((error) => error.message.includes("invalid uninhabited record tag")), + ).toBe(true); +}); + +test("uninhabited union tags require distinct record arms", () => { + const mod = discriminatedModule(); + mod.unions![0]!.uninhabited = [1]; + expect(validateModule(mod)).toEqual([]); + expect(deserializeModule(serializeModule(mod))).toEqual(mod); + mod.unions![0]!.uninhabited = [1, 1]; + expect( + validateModule(mod).some((error) => error.message.includes("invalid uninhabited record tag")), + ).toBe(true); +}); diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index e0f7a304a2..962778239e 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -742,6 +742,16 @@ export function validateModule(mod: IrModule): IrValidationError[] { if (u.arms.length < 2) { errors.push({ message: `union ${u.id}: fewer than 2 arms`, loc: noLoc }); } + const uninhabited = new Set(); + for (const tag of u.uninhabited ?? []) { + if (!Number.isInteger(tag) || u.arms[tag]?.kind !== "record" || uninhabited.has(tag)) { + errors.push({ + message: `union ${u.id}: invalid uninhabited record tag ${tag}`, + loc: noLoc, + }); + } + uninhabited.add(tag); + } if (u.discriminant) { const { field, cases } = u.discriminant; const tags = new Set(); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/result-never-widening.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/result-never-widening.ts.json new file mode 100644 index 0000000000..c497cbae28 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/result-never-widening.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/result-never-widening.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/type-mapper-unions.test.ts b/packages/compiler/test/type-mapper-unions.test.ts index c714df4d75..e793c12020 100644 --- a/packages/compiler/test/type-mapper-unions.test.ts +++ b/packages/compiler/test/type-mapper-unions.test.ts @@ -26,6 +26,16 @@ beforeAll(() => { writeFileSync( entry, ` + type Result = { ok: true; value: T } | { ok: false; error: E }; + export type NumberError = Result; + export type SharedLayout = Result | { ok: false; error: number }; + export type IntersectionNever = { ok: true; value: string } | ({ ok: false } & { error: never }); + export type SuccessOnly = Result; + export type FailureOnly = Result; + export type OptionalNever = { ok: true; value: string } | { ok: false; error?: never }; + export type NeverArray = { ok: true; value: string } | { ok: false; error: never[] }; + export type NeverCallback = { ok: true; value: string } | { ok: false; error: () => never }; + export type NeverGetter = { ok: true; value: string } | { ok: false; get error(): never }; export type Choice = { kind: "a" | "alias"; value: number } | { kind: "b"; text: string }; export type Other = { kind: "x"; value: number } | { kind: "y"; text: string }; export type Numeric = { kind: 0; value: number } | { kind: -2.5; text: string }; @@ -187,3 +197,47 @@ describe("checker and synthesized optional union identity", () => { expect(original.arms.some((arm) => arm.kind === "undefinedT")).toBe(false); }); }); + +describe("uninhabited record union members", () => { + test.each(["SuccessOnly", "FailureOnly", "IntersectionNever"])( + "%s marks only its impossible record", + (name) => { + const ctx = context(); + const result = mapped(name, ctx); + const union = ctx.unions.get(result.unionId)!; + expect(union.arms).toHaveLength(2); + expect(union.uninhabited).toHaveLength(1); + const arm = union.arms[union.uninhabited![0]!]!; + expect(arm.kind).toBe("record"); + if (arm.kind !== "record") return; + expect(ctx.shapes.get(arm.shapeId)!.fields.map((field) => field.name)).toEqual( + name === "FailureOnly" ? ["ok", "value"] : ["error", "ok"], + ); + const optional = withUndefinedArm(result, ctx.unions)!; + expect(optional.kind).toBe("union"); + if (optional.kind !== "union") return; + expect(ctx.unions.get(optional.unionId)!.uninhabited).toHaveLength(1); + }, + ); + + test.each([true, false])( + "never and number contracts remain distinct (never first: %s)", + (first) => { + const ctx = context(); + const names = first ? ["SuccessOnly", "NumberError"] : ["NumberError", "SuccessOnly"]; + const [a, b] = names.map((name) => mapped(name, ctx)); + expect(a!.unionId).not.toBe(b!.unionId); + expect(ctx.unions.get(mapped("NumberError", ctx).unionId)!.uninhabited).toBeUndefined(); + }, + ); + + test.each(["OptionalNever", "NeverArray", "NeverCallback", "NeverGetter", "SharedLayout"])( + "%s retains its inhabited alternative", + (name) => { + const ctx = context(); + const result = mapped(name, ctx); + expect(ctx.unions.get(result.unionId)!.arms).toHaveLength(2); + expect(ctx.unions.get(result.unionId)!.uninhabited).toBeUndefined(); + }, + ); +}); diff --git a/tests/corpus/result-never-widening.ts b/tests/corpus/result-never-widening.ts new file mode 100644 index 0000000000..fd6ad55694 --- /dev/null +++ b/tests/corpus/result-never-widening.ts @@ -0,0 +1,53 @@ +type Result = { ok: true; value: T } | { ok: false; error: E }; + +function ok(value: T): Result { + return { ok: true, value }; +} + +function err(error: E): Result { + return { ok: false, error }; +} + +function parse(value: string): Result { + return value.length > 0 ? ok(value) : err("empty"); +} + +function receive(value: Result): void { + console.log(JSON.stringify(value)); +} + +receive(parse("hello")); +receive(parse("")); +receive(ok("argument")); +receive(err("argument-error")); + +const numberResult: Result = ok(42); +const objectResult: Result<{ name: string }, string> = ok({ name: "Ada" }); +const arrayResult: Result = ok(["a", "b"]); +const errorResult: Result = err({ code: 7 }); +console.log(JSON.stringify(numberResult)); +console.log(JSON.stringify(objectResult)); +console.log(JSON.stringify(arrayResult)); +console.log(JSON.stringify(errorResult)); + +function wrapped(): { result: Result } { + const source = { result: ok("nested") }; + return source; +} +console.log(JSON.stringify(wrapped())); + +const source: Result[] = [ok("first"), ok("second")]; +const destination: Result[] = source; +console.log(JSON.stringify(destination)); + +type OptionalResult = { ok: true; value: string } | { ok: false; error?: never }; +function optionalResult(): OptionalResult { + return { ok: false }; +} +console.log(JSON.stringify(optionalResult())); + +function unwrap(result: Result): string { + if (result.ok) return result.value; + throw result.error; +} +console.log(unwrap(ok("unreachable error")));