From 15cc7310aca7924abc26f6d2ef91c0543ab945ef Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Fri, 23 Oct 2026 15:15:34 -0700 Subject: [PATCH] Keep record values that subtype reduction folds into an inferred type TypeScript infers a function's return type as the subtype-reduced union of the values it returns: returning `{ status }` in one branch and `{ status, value }` (or a call returning either) in another infers just `{ status }`. Records have fixed layouts, so that type cannot hold the second value: the return failed its union wrap at run time ('... is not representable in the target union'), and values that reached a binding of the reduced type failed a checked narrowing. - A function with an inferred return type and no contextual signature returns the union of the record types it actually returns, including through calls of other such functions. Only value types the checker's reduction dropped from the inferred union are added, so functions whose inferred type already names every returned type keep their layout. Declared return types keep their meaning. - Reading such a union through the reduced type (a binding, a member read, JSON.stringify) uses a dedicated checked extraction that converts the wider record arms with the ordinary width coercion; other narrowings are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/builtins/json.ts | 10 +- .../src/frontend/lowering/coercions/state.ts | 9 +- .../src/frontend/lowering/coercions/unions.ts | 71 ++++++++- .../lowering/expressions/property-access.ts | 20 ++- .../src/frontend/lowering/function-returns.ts | 143 ++++++++++++++++++ .../compiler/src/frontend/lowering/lowerer.ts | 10 +- .../src/frontend/lowering/union-narrow.ts | 17 +++ ...nferred-return-overlapping-records.ts.json | 6 + ...990-inferred-return-overlapping-records.ts | 59 ++++++++ ...nferred-return-overlapping-records.ts.json | 1 + 10 files changed, 340 insertions(+), 6 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7990-inferred-return-overlapping-records.ts.json create mode 100644 tests/corpus/7990-inferred-return-overlapping-records.ts create mode 100644 tests/harness/corpus-costs/7990-inferred-return-overlapping-records.ts.json diff --git a/packages/compiler/src/frontend/lowering/builtins/json.ts b/packages/compiler/src/frontend/lowering/builtins/json.ts index 64f829865..5aad5077f 100644 --- a/packages/compiler/src/frontend/lowering/builtins/json.ts +++ b/packages/compiler/src/frontend/lowering/builtins/json.ts @@ -1,3 +1,4 @@ +import { widensReducedRecords } from "../function-returns.js"; import { keyedHelperName } from "../stable-names.js"; import { isDateJsonSource } from "./dates.js"; import { dynUndefinedExpr, varRef } from "../../../ir/build.js"; @@ -96,7 +97,14 @@ export function lowerJsonMethodCall( !isDateJsonSource(lowerer, argNode) ) { const narrowed = lowerer.mapTypeOf(lowerer.typeOf(argNode)); - if (narrowed && !isUnitType(narrowed) && narrowed.kind !== "void") { + // A union of record types the checker's subtype reduction spelled as + // one of them serializes each value by its own layout. + if ( + narrowed && + !isUnitType(narrowed) && + narrowed.kind !== "void" && + !widensReducedRecords(lowerer, narrowed, value.type) + ) { const helper = narrowed.kind === "union" ? lowerer.narrowedRetagHelper(argNode, value.type.unionId, narrowed.unionId, loc) diff --git a/packages/compiler/src/frontend/lowering/coercions/state.ts b/packages/compiler/src/frontend/lowering/coercions/state.ts index ad1d7c6ea..3c2ecf7bd 100644 --- a/packages/compiler/src/frontend/lowering/coercions/state.ts +++ b/packages/compiler/src/frontend/lowering/coercions/state.ts @@ -12,7 +12,14 @@ export class CoercionState { // Provenance for checked extraction; consumers must not infer it from names. readonly checkedNarrows = new Set(); // The extracted arm of each checked (non-defaulting) extraction helper. - readonly checkedNarrowArms = new Map(); + readonly checkedNarrowArms = new Map< + string, + { unionId: string; tag: number; target: IrType; widens?: true } + >(); readonly retags = new Map(); readonly narrows = new Map(); + readonly widthNarrows = new Map(); + /** Unions widenReducedReturn produced: only these read through the + * width-converting extraction (widthNarrowHelper). */ + readonly widenedReturns = new Set(); } diff --git a/packages/compiler/src/frontend/lowering/coercions/unions.ts b/packages/compiler/src/frontend/lowering/coercions/unions.ts index 434e687a5..776892487 100644 --- a/packages/compiler/src/frontend/lowering/coercions/unions.ts +++ b/packages/compiler/src/frontend/lowering/coercions/unions.ts @@ -421,6 +421,46 @@ export function narrowedArmHelper( return name; } +/** Interned `%union.narrow.width.(u)`: the checked extraction of a + * record arm from a union that also holds records whose fields strictly + * include the arm's (a function's widened inferred return, see + * widenReducedReturn). Those arms convert like any record width coercion; + * every other arm throws like narrowedArmHelper's. */ +export function widthNarrowHelper( + lowerer: Lowerer, + fromId: string, + target: IrType, + loc: SrcLoc, +): string | null { + const from = lowerer.unions.get(fromId); + if (!from || target.kind !== "record") return null; + const tag = lowerer.armTag(fromId, target); + if (tag < 0) return null; + const key = `${fromId}:${tag}`; + const existing = lowerer.coercions.widthNarrows.get(key); + if (existing) return existing; + const name = keyedHelperName(lowerer, "%union.narrow.width", key); + lowerer.coercions.widthNarrows.set(key, name); + const fn = buildUnionNarrow( + name, + from, + target, + loc, + (type) => lowerer.fmt(type), + null, + (armTag, payload) => widthSubtypeConversion(lowerer, from.arms[armTag]!, target, payload, loc), + ); + if (!fn) throw new InternalCompilerError("lowerer bug: invalid checked union extraction"); + lowerer.coercions.checkedNarrowArms.set(name, { + unionId: fromId, + tag, + target: from.arms[tag]!, + widens: true, + }); + lowerer.liftedFns.push(fn); + return name; +} + /** A checked extraction at its use. A plain read tests the stored tag in * place and projects the payload directly, so member reads can borrow it; * every other arm throws the helper's TypeError with the same message. @@ -442,7 +482,8 @@ export function checkedNarrowCall( value.type.kind !== "union" || value.type.unionId !== arm.unionId || !typeEquals(type, arm.target) || - !isSafeToRepeat(value) + !isSafeToRepeat(value) || + arm.widens === true ) return call; const unionId = arm.unionId; @@ -599,3 +640,31 @@ export function deferredReadHelper( lowerer.liftedFns.push(fn); return name; } + +/** Whether `arm` is a record whose fields strictly include those of the + * record `target`: a value of the target type under TypeScript's structural + * typing, which the checker's subtype reduction may have spelled as the + * target (`{ status } | { status, value }` infers `{ status }`). */ +export function isWidthSubtypeRecord(lowerer: Lowerer, arm: IrType, target: IrType): boolean { + if (arm.kind !== "record" || target.kind !== "record") return false; + const own = lowerer.shapes.get(arm.shapeId); + const want = lowerer.shapes.get(target.shapeId); + if (!own || !want || own.tuple || want.tuple || own.indexValue || want.indexValue) return false; + if (want.fields.length >= own.fields.length) return false; + const names = new Set(own.fields.map((f) => f.name)); + return want.fields.every((f) => names.has(f.name)); +} + +/** A checked narrow that meets a width-subtype record arm converts it like + * any record width coercion instead of reporting a lying narrowing. */ +function widthSubtypeConversion( + lowerer: Lowerer, + arm: IrType, + target: IrType, + payload: IrExpr, + loc: SrcLoc, +): IrExpr | null { + if (!isWidthSubtypeRecord(lowerer, arm, target)) return null; + const lift = lowerer.widthLiftPlan(arm, target); + return lift === null ? null : lowerer.applyWidthLift(lift, payload, target, loc); +} diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 9894cfb71..bac0d0daa 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -1,3 +1,5 @@ +import { widensReducedRecords } from "../function-returns.js"; +import { widthNarrowHelper } from "../coercions/unions.js"; /* Property reads (`a.b`): lowerExprInner's property-access branch, intrinsic and union properties, field reads and targets, and match-group reads. */ import { keyedHelperName } from "../stable-names.js"; import { staticallySelected } from "../static-typeof.js"; @@ -1293,7 +1295,23 @@ export function lowerPropertyAccessExpr( // honest one, not a generic recitation about the outer dot. A // receiver that lowers cleanly means the MEMBER is the gap — name // the property and the receiver's type instead of "property access". - const recvLowered = lowerer.lowerExpr(expr.expression); + let recvLowered = lowerer.lowerExpr(expr.expression); + // A receiver whose value union holds record types the checker's subtype + // reduction folded into its type (`{ status } | { status, value }` typed + // `{ status }`, a call of a function with an inferred return type): read + // through the checker's type, converting the wider arms. + if (recvLowered.type.kind === "union") { + const declared = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); + if ( + declared !== null && + declared.kind === "record" && + widensReducedRecords(lowerer, declared, recvLowered.type) + ) { + const helper = widthNarrowHelper(lowerer, recvLowered.type.unionId, declared, loc); + if (helper !== null) + recvLowered = lowerer.checkedNarrowCall(helper, recvLowered, declared, loc); + } + } // A dyn receiver — JSON.parse's `any`, or `unknown` the checker // narrowed to `object` — reads through the dyn keyed read: member // or undefined (JS's own-property answer), throwing JS's TypeError diff --git a/packages/compiler/src/frontend/lowering/function-returns.ts b/packages/compiler/src/frontend/lowering/function-returns.ts index dde109ea7..b1132a8b9 100644 --- a/packages/compiler/src/frontend/lowering/function-returns.ts +++ b/packages/compiler/src/frontend/lowering/function-returns.ts @@ -11,12 +11,14 @@ import { VOID, isUnitType, typeEquals, + typeKey, } from "../../ir/ir.js"; import { isJsSourceFile } from "../program.js"; import { PoisonError, dynFallbackType } from "./lowerer.js"; import { functionCanReturnUndefined } from "../function-completion.js"; import { jsBindingAppendsOnly, jsBindingHasOpenWrites } from "./lower-stmts.js"; import type { FnCtx } from "./function-context.js"; +import { constituentTypes } from "../ts7/checker.js"; /** Keep the conservative completion rule shared by return construction * and inference. Nested control flow may still make completion unreachable. */ @@ -175,6 +177,8 @@ export function declaredReturnType( // (`.action(() => { throw ... })`). `never` VALUES stay unmapped. if (retTsType.flags & ts.TypeFlags.Never) return VOID; let mappedReturn = lowerer.mapTypeOf(retTsType); + if (mappedReturn !== null) + mappedReturn = widenReducedReturn(lowerer, decl, retTsType, mappedReturn); // A generator declared to return Iterable returns a generator over T. if (mappedReturn && decl.asteriskToken) mappedReturn = iterableGeneratorArm(lowerer, mappedReturn); @@ -522,3 +526,142 @@ export function blockBodyOf(decl: ts.FunctionLikeDeclaration): ts.Block | null { if (ts.isBlock(body)) return body; return ts.forEachChild(decl, (c) => (ts.isBlock(c) ? c : undefined)) ?? null; } + +const widening = new Set(); + +/** An inferred return type is the checker's SUBTYPE-REDUCED union of the + * returned values' types: `return aborted` (`{ status }`) beside `return + * { status, value }` infers just `{ status }`, because the second is a + * subtype of the first. Records have fixed layouts, so that type cannot + * hold the second value: the return would drop `value` or fail the union + * wrap. A function whose return type is inferred therefore returns the + * union of the record types it actually returns. Declared return types keep + * their meaning. */ +function widenReducedReturn( + lowerer: Lowerer, + decl: ts.SignatureDeclaration, + retTsType: ts.Type, + mapped: IrType, +): IrType { + if (decl.type !== undefined || hasExplicitJsDocReturn(decl)) return mapped; + const body = (decl as { body?: ts.Node }).body; + if (body === undefined || decl.asteriskToken !== undefined) return mapped; + if (decl.modifiers?.nodes.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)) return mapped; + // A function expression with a contextual signature returns that + // signature's type; only free-standing inference is widened. + if ( + (ts.isFunctionExpression(decl) || ts.isArrowFunction(decl)) && + lowerer.checker.getContextualType(decl) !== undefined + ) + return mapped; + const arms = + mapped.kind === "union" ? (lowerer.unions.get(mapped.unionId)?.arms ?? []) : [mapped]; + const recordArms = arms.filter((a) => a.kind === "record"); + if (recordArms.length === 0 || widening.has(decl)) return mapped; + const checkerArms = new Set( + retTsType.flags & ts.TypeFlags.Union ? constituentTypes(retTsType) : [retTsType], + ); + const fieldsOf = (t: IrType): readonly string[] | null => { + if (t.kind !== "record") return null; + const shape = lowerer.shapes.get(t.shapeId); + return shape === undefined || shape.tuple || shape.indexValue !== undefined + ? null + : shape.fields.map((f) => f.name); + }; + const narrower = (part: IrType): boolean => { + const own = fieldsOf(part); + if (own === null || arms.some((a) => typeKey(a) === typeKey(part))) return false; + return recordArms.some((arm) => { + const fields = fieldsOf(arm); + return fields !== null && fields.length < own.length && fields.every((f) => own.includes(f)); + }); + }; + const extra = new Map(); + const returned = (expr: ts.Expression): void => { + // A call of another inferred function returns ITS widened type. + const callee = ts.isCallExpression(expr) + ? lowerer.checker.getResolvedSignature(expr)?.declaration + : undefined; + if ( + callee !== undefined && + ts.isFunctionLike(callee) && + (callee as { body?: ts.Node }).body !== undefined && + callee.type === undefined + ) { + let t: IrType | null = null; + widening.add(decl); + try { + t = declaredReturnType(lowerer, callee, expr); + } catch { + t = null; + } finally { + widening.delete(decl); + } + if (t === null) return; + const parts = t.kind === "union" ? (lowerer.unions.get(t.unionId)?.arms ?? []) : [t]; + for (const part of parts) if (narrower(part)) extra.set(typeKey(part), part); + return; + } + // Only a value type the checker's reduction dropped from the inferred + // union is a candidate; a constituent of the inferred type is already + // represented by it. + const valueType = lowerer.typeOf(expr); + const valueArms = + valueType.flags & ts.TypeFlags.Union ? constituentTypes(valueType) : [valueType]; + for (const part of valueArms) { + if (checkerArms.has(part) || !(part.flags & ts.TypeFlags.Object)) continue; + if (!lowerer.checker.isTypeAssignableTo(part, retTsType)) continue; + const t = lowerer.mapTypeOf(part); + if (t !== null && narrower(t)) extra.set(typeKey(t), t); + } + }; + const scan = (node: ts.Node): void => { + if (node !== decl && ts.isFunctionLike(node)) return; + if (ts.isReturnStatement(node) && node.expression !== undefined) returned(node.expression); + ts.forEachChild(node, scan); + }; + if (ts.isArrowFunction(decl) && !ts.isBlock(body)) returned(body as ts.Expression); + else scan(body); + if (extra.size === 0) return mapped; + const joined = new Map(); + for (const a of [...arms, ...extra.values()]) joined.set(typeKey(a), a); + const sorted = [...joined.values()].sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + const unionId = lowerer.unions.intern(sorted); + lowerer.coercions.widenedReturns.add(unionId); + return { kind: "union", unionId }; +} + +/** Whether `wide` is a widened inferred return that holds `narrow`'s arms plus records that are width + * subtypes of `narrow`'s record arms: the shape a subtype-reduced inferred + * type takes next to the values it was inferred from. */ +export function widensReducedRecords(lowerer: Lowerer, narrow: IrType, wide: IrType): boolean { + if (wide.kind !== "union" || typeKey(narrow) === typeKey(wide)) return false; + if (!lowerer.coercions.widenedReturns.has(wide.unionId)) return false; + const narrowArms = + narrow.kind === "union" ? (lowerer.unions.get(narrow.unionId)?.arms ?? []) : [narrow]; + const wideArms = lowerer.unions.get(wide.unionId)?.arms ?? []; + const keys = new Set(wideArms.map(typeKey)); + if (!narrowArms.every((a) => keys.has(typeKey(a)))) return false; + const fieldsOf = (t: IrType): readonly string[] | null => { + if (t.kind !== "record") return null; + const shape = lowerer.shapes.get(t.shapeId); + return shape === undefined || shape.tuple || shape.indexValue !== undefined + ? null + : shape.fields.map((f) => f.name); + }; + const narrowKeys = new Set(narrowArms.map(typeKey)); + const extras = wideArms.filter((a) => !narrowKeys.has(typeKey(a))); + return ( + extras.length > 0 && + extras.every((extra) => { + const own = fieldsOf(extra); + return ( + own !== null && + narrowArms.some((arm) => { + const f = fieldsOf(arm); + return f !== null && f.length < own.length && f.every((x) => own.includes(x)); + }) + ); + }) + ); +} diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 7aad166e9..4bf938027 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -115,6 +115,7 @@ import { checkedNarrowCall, checkedNarrowOperand, deferredReadHelper, + widthNarrowHelper, } from "./coercions/unions.js"; import { lowerUtilTypeValue, @@ -443,7 +444,7 @@ import { bindingNeverReassigned } from "./binding-analysis.js"; import type { InstantiationPath } from "./instantiation-path.js"; import { bodyReadsArguments } from "../arguments-usage.js"; import { completeArgs, wrappedUndefined, undefinedArgFor } from "./call-arguments.js"; -import { bodyReturnType, declaredReturnType } from "./function-returns.js"; +import { bodyReturnType, declaredReturnType, widensReducedRecords } from "./function-returns.js"; import { lowerCall, lowerPromiseMethodCall, lowerFilterNarrowCall } from "./lower-calls.js"; import { lowerFfiCall, validateFfiImports } from "./native-ffi.js"; import { @@ -3464,6 +3465,7 @@ export class Lowerer { this.internTableHelpers, this.coercions.retags, this.coercions.narrows, + this.coercions.widthNarrows, ]) for (const name of table.values()) interned.add(name); for (const fn of [...this.liftedFns, ...this.implicitFns]) @@ -10013,7 +10015,11 @@ export class Lowerer { expected.kind !== "void" && this.armTag(expr.type.unionId, expected) >= 0 ) { - const helper = this.narrowedArmHelper(expr.type.unionId, expected, expr.loc); + // A function's widened inferred return (widenReducedReturn) also + // holds records with more fields; those convert instead of throwing. + const helper = widensReducedRecords(this, expected, expr.type) + ? widthNarrowHelper(this, expr.type.unionId, expected, expr.loc) + : this.narrowedArmHelper(expr.type.unionId, expected, expr.loc); if (helper) { return this.checkedNarrowCall(helper, expr, expected, expr.loc); } diff --git a/packages/compiler/src/frontend/lowering/union-narrow.ts b/packages/compiler/src/frontend/lowering/union-narrow.ts index 3bb09cb26..7ae036fad 100644 --- a/packages/compiler/src/frontend/lowering/union-narrow.ts +++ b/packages/compiler/src/frontend/lowering/union-narrow.ts @@ -28,6 +28,9 @@ export function buildUnionNarrow( loc: SrcLoc, formatType: (type: IrType) => string, undefinedDefault: IrExpr | null = null, + /** Converts another arm's payload into the target (a record arm that is + * a width subtype of a record target), or null to throw. */ + convert: ((tag: number, payload: IrExpr) => IrExpr | null) | null = null, ): IrFunction | null { if (isUnitType(target) || target.kind === "void") return null; const targetTag = from.arms.findIndex((arm) => typeEquals(arm, target)); @@ -43,6 +46,7 @@ export function buildUnionNarrow( const inputType: IrType = { kind: "union", unionId: from.id }; const input: IrExpr = { kind: "varRef", localId: "u.0", type: inputType, loc }; const body: IrStmt[] = []; + let converted: IrExpr | null = null; for (let tag = 0; tag < from.arms.length; tag++) { const arm = from.arms[tag]!; let branch: IrStmt[]; @@ -56,6 +60,19 @@ export function buildUnionNarrow( ]; } else if (arm.kind === "undefinedT" && undefinedDefault !== null) { branch = [{ kind: "return", value: undefinedDefault, loc }]; + } else if ( + convert !== null && + !isUnitType(arm) && + (converted = convert(tag, { + kind: "unionNarrow", + unionId: from.id, + tag, + value: input, + type: arm, + loc, + })) !== null + ) { + branch = [{ kind: "return", value: converted, loc }]; } else { branch = [ { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7990-inferred-return-overlapping-records.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7990-inferred-return-overlapping-records.ts.json new file mode 100644 index 000000000..6a55f1f2e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7990-inferred-return-overlapping-records.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7990-inferred-return-overlapping-records.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7990-inferred-return-overlapping-records.ts b/tests/corpus/7990-inferred-return-overlapping-records.ts new file mode 100644 index 000000000..c2d1c9ea0 --- /dev/null +++ b/tests/corpus/7990-inferred-return-overlapping-records.ts @@ -0,0 +1,59 @@ +// Functions whose inferred return type is a union of overlapping records. +// TypeScript's subtype reduction infers `{ status }` for a function that +// returns `{ status }` in one branch and `{ status, value }` in another, so +// the inferred type is narrower than the values. Returned values keep their +// fields, chained returns keep them, and bindings typed by the reduced type +// read their declared fields. +type Aborted = { status: string }; +const ABORTED: Aborted = { status: "aborted" }; + +function inner(ok: boolean, value: unknown) { + if (!ok) return ABORTED; + return { status: "valid", value }; +} + +// Returns inner's union through a call: inferred as `{ status }` alone. +function outer(ok: boolean, value: unknown) { + if (value === "stop") return ABORTED; + return inner(ok, value); +} + +function merge(entries: [string, number][]) { + const result: Record = {}; + for (const [key, n] of entries) { + if (key === "__proto__") return ABORTED; + result[key] = n; + } + return { status: "valid", value: result }; +} + +function parse(entries: [string, number][]) { + if (entries.length === 0) return ABORTED; + return merge(entries); +} + +console.log(JSON.stringify(outer(true, [1, 2]))); +console.log(JSON.stringify(outer(false, 1))); +console.log(JSON.stringify(outer(true, "stop"))); +console.log(JSON.stringify(parse([["a", 1], ["b", 2]]))); +console.log(JSON.stringify(parse([]))); + +// Into untyped positions: the value keeps every field. +const seen: unknown[] = []; +seen.push(outer(true, { nested: true })); +seen.push(parse([["c", 3]])); +console.log(JSON.stringify(seen)); +const loose: unknown = outer(true, "kept"); +console.log(JSON.stringify(loose)); + +// Bindings typed by the reduced type: declared fields read as in Node. +const r = outer(true, 42); +console.log(r.status); +let status = ""; +for (const ok of [true, false]) status += outer(ok, 0).status + " "; +console.log(status.trim()); +function statusOf(ok: boolean): string { + const local = outer(ok, null); + return local.status; +} +console.log(statusOf(true), statusOf(false)); diff --git a/tests/harness/corpus-costs/7990-inferred-return-overlapping-records.ts.json b/tests/harness/corpus-costs/7990-inferred-return-overlapping-records.ts.json new file mode 100644 index 000000000..7da9b7545 --- /dev/null +++ b/tests/harness/corpus-costs/7990-inferred-return-overlapping-records.ts.json @@ -0,0 +1 @@ +{ "plain": 3700 }