From ee63d4a60751e27e27fb8c76bff5c7d9bb404d78 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 10:57:54 -0700 Subject: [PATCH] Distribute conditional members over a generic body's bound parameter A generic body reading a member typed by a distributive conditional over its type parameter (`opts?: T extends "alpha" ? AlphaOpts : T extends "beta" ? BetaOpts : never`) refused it with SC2009 when the instance bound T to a union of literals (`"alpha" | "gamma"`, `"a" | (string & {})`), and mapped it as `number | undefined` (never's placeholder) when the binding selected only never, while the concrete instantiation maps that member as `null | undefined`; values crossing between the two failed with SC2003. The conditional now distributes over the binding as the checker does: each member evaluates, the non-never results form the union, an open `string & {}` member checks as `string`, and an all-never result leaves an optional member that holds only undefined. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/type-mapper.ts | 99 ++++++++++++++++- ...eneric-conditional-member-bindings.ts.json | 6 ++ .../generic-conditional-member-bindings.ts | 100 ++++++++++++++++++ ...eneric-conditional-member-bindings.ts.json | 1 + 4 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-conditional-member-bindings.ts.json create mode 100644 tests/corpus/generic-conditional-member-bindings.ts create mode 100644 tests/harness/corpus-costs/generic-conditional-member-bindings.ts.json diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 17f9d0d11..7124db1b5 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -2057,6 +2057,11 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { return viaConditional; } } + const viaDistribution = mapDistributedConditional(type, ctx); + if (viaDistribution !== null) { + contextResolutions++; + return viaDistribution; + } } } const widened = checker.getBaseTypeOfLiteralType(type); @@ -5011,11 +5016,19 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { !checker.isNeverType(part) && !isEmptyObjectLiteralType(part, ctx), ); + let neverConditionalPart = false; + let undefinedPart: ts.Type | undefined; for (const part of parts) { if (hasArrayPart && emptyTuple(part)) continue; // TypeScript's client can retain impossible intersections in a // distributed union. They have no inhabitants and no runtime tag. if (checker.isNeverType(part)) continue; + // A conditional over a bound parameter whose every member selects + // `never` contributes no arm either. + if (distributedConditionalParts(part, ctx)?.length === 0) { + neverConditionalPart = true; + continue; + } // A `void` PART is inhabited only by undefined (`Promise | // void` return types, `string | void`): it becomes the undefinedT // unit arm, exactly like an undefined part — the value either @@ -5023,6 +5036,7 @@ 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); + undefinedPart ??= part; continue; } if (part.flags & ts.TypeFlags.Null) { @@ -5065,6 +5079,11 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { } const arms = [...byKey.values()]; if (arms.length === 0) return F64; // same unreachable placeholder as standalone never + // `Cond | undefined` whose conditional selects never for the + // instance's binding is the plain `undefined` the concrete + // instantiation's optional member has. + if (neverConditionalPart && arms.length === 1 && undefinedPart !== undefined) + return mapType(undefinedPart, ctx); // A single surviving UNIT arm cannot stand alone (degenerate — the // checker collapsed everything else away); anything else single is // just that type. A single arm UNDER A MINTED PLACEHOLDER cannot @@ -6136,6 +6155,81 @@ function mapGenericIndexedAccess(type: ts.Type, ctx: TypeMapperCtx): IrType | nu return { kind: "union", unionId: ctx.unions.intern(arms) }; } +/** A distributive conditional (`T extends "a" ? A : T extends "b" ? B : + * never`, its check type a naked type parameter) over a parameter the + * instance binds: evaluated once per member of the binding (`"a" | "b"`, + * or the open `"a" | (string & {})` spelling), as the checker distributes + * it. Answers the non-never results (empty when every member selects + * never, so an optional member of that type holds only undefined, as in + * the concrete instantiation), or null when the conditional is another + * shape or a member does not evaluate. */ +function distributedConditionalParts(type: ts.Type, ctx: TypeMapperCtx): ts.Type[] | null { + const { checker, resolveTypeParamTs } = ctx; + if (!resolveTypeParamTs || !type.isConditionalType()) return null; + const checkType = type.getCheckType(); + if (!checkType || !(checkType.flags & ts.TypeFlags.TypeParameter)) return null; + const symbol = checkType.getSymbol(); + const bound = resolveTypeParamTs(checkType); + if (!symbol || !bound) return null; + const parts: ts.Type[] = []; + for (const member of bound.isUnionType() ? ts.constituentTypes(bound) : [bound]) { + // `string & {}` (the open-union spelling) extends exactly what `string` + // extends: the empty object adds nothing to a primitive. + const emptyObject = (part: ts.Type): boolean => + (part.flags & ts.TypeFlags.Object) !== 0 && + checker.getPropertiesOfType(part).length === 0 && + checker.getIndexInfosOfType(part).length === 0 && + checker.getCallSignatures(part).length === 0 && + checker.getConstructSignatures(part).length === 0; + const primitive = member.isIntersectionType() + ? ts.constituentTypes(member).filter((part) => !emptyObject(part)) + : [member]; + if (primitive.length !== 1) return null; + const evaluated = evalMappedTemplate(type, new Map(), new Map([[symbol, primitive[0]!]]), ctx); + if (!evaluated) return null; + if (checker.isNeverType(evaluated)) continue; + if (!parts.includes(evaluated)) parts.push(evaluated); + } + return parts; +} + +/** A unit-only type once its conditionals over bound parameters are + * distributed: `Cond | undefined` where every member of T's binding + * selects never (the optional member's type in a generic body). */ +function neverConditionalUnitOnly(t: ts.Type, ctx: TypeMapperCtx): boolean { + const UNIT = ts.TypeFlags.Undefined | ts.TypeFlags.Void | ts.TypeFlags.Null; + if (!t.isUnionType()) return false; + let conditional = false; + for (const part of ts.constituentTypes(t)) { + if ((part.flags & UNIT) !== 0) continue; + if (distributedConditionalParts(part, ctx)?.length !== 0) return false; + conditional = true; + } + return conditional; +} + +/** mapType of a distributed conditional (distributedConditionalParts): the + * union of its members' results. Null when it does not distribute, when a + * result does not map, or when every member selects never (the enclosing + * union drops such a part). */ +function mapDistributedConditional(type: ts.Type, ctx: TypeMapperCtx): IrType | null { + const parts = distributedConditionalParts(type, ctx); + if (!parts || parts.length === 0) return null; + const arms: IrType[] = []; + for (const part of parts) { + const mapped = mapType(part, ctx); + if (!mapped) return null; + const members = + mapped.kind === "union" ? (ctx.unions.get(mapped.unionId)?.arms ?? null) : [mapped]; + if (!members) return null; + for (const arm of members) if (!arms.some((a) => typeEquals(a, arm))) arms.push(arm); + } + if (arms.length === 1) return arms[0]!; + if (!unionContainerArmsOk(arms)) return null; + arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + return { kind: "union", unionId: ctx.unions.intern(arms) }; +} + /** `keyof T extends never ? A : B` over a bound record T (the lib's * Object.entries element spelling): decided by whether the binding has any * key. */ @@ -8242,7 +8336,10 @@ function mapRecordTypeInner( // absent-field idiom — and `{ p: undefined }` spellings): the // unit-only union. The runtime value is the interned unit, JSON // omits the undefined arm exactly like an omitted optional. - if (pt?.kind === "void" && isUnitOnlyTsType(fieldTs, ctx.resolveTypeParam)) + if ( + pt?.kind === "void" && + (isUnitOnlyTsType(fieldTs, ctx.resolveTypeParam) || neverConditionalUnitOnly(fieldTs, ctx)) + ) pt = unitOnlyUnion(ctx.unions); if (pt !== null && isUnitOnlyIr(pt, ctx) && jsObjectLiteralProperty(p)) pt = DYN; if (pt?.kind === "array" && jsNeverArrayProp(p, fieldTs, checker)) pt = DYN; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-conditional-member-bindings.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-conditional-member-bindings.ts.json new file mode 100644 index 000000000..398cc39e9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-conditional-member-bindings.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/generic-conditional-member-bindings.ts" + ], + "diags": [] +} diff --git a/tests/corpus/generic-conditional-member-bindings.ts b/tests/corpus/generic-conditional-member-bindings.ts new file mode 100644 index 000000000..60631e191 --- /dev/null +++ b/tests/corpus/generic-conditional-member-bindings.ts @@ -0,0 +1,100 @@ +// Members typed by a distributive conditional over a generic parameter +// (`opts?: T extends "alpha" ? AlphaOpts : ...`), read inside the generic +// body: the instance's binding decides the layout, member by member of a +// literal union, and an all-never binding leaves an optional member that +// holds only undefined, as the concrete instantiation's member does. + +type KnownApi = "alpha" | "beta" | "gamma"; +type Api = KnownApi | (string & {}); + +interface AlphaOpts { + fast?: boolean; +} +interface BetaOpts { + depth?: number; +} + +interface Base { + id: string; + api: TApi; +} + +interface Item extends Base { + kind?: "item"; + opts?: TApi extends "alpha" ? AlphaOpts : TApi extends "beta" ? BetaOpts : never; +} + +interface Other extends Base { + kind: "other"; + size: number; +} + +type AnyItem = Item | Other; + +interface Options { + name: string; + items: readonly AnyItem[]; +} + +interface Built { + name: string; + all: () => readonly AnyItem[]; + chat: () => Item[]; + add: (item: AnyItem) => void; +} + +function describe(item: Item): string { + return `${item.id}:${item.api}:${item.opts === undefined ? "-" : JSON.stringify(item.opts)}`; +} + +function build(input: Options): Built { + const baseline = input.items; + let dynamic: readonly AnyItem[] = []; + const current = (): readonly AnyItem[] => { + const merged = [...baseline]; + for (const item of dynamic) merged.push(item); + return merged; + }; + return { + name: input.name, + all: current, + chat: () => current().filter((item): item is Item => item.kind !== "other"), + add: (item) => { + dynamic = [...dynamic, item]; + }, + }; +} + +function report(built: Built): void { + console.log(`${built.name}: ${built.all().length} items, ${built.chat().length} chat`); + for (const item of built.chat()) console.log(` ${describe(item)}`); +} + +const mixed: Built<"alpha" | "gamma"> = build({ + name: "mixed", + items: [ + { id: "a1", api: "alpha", opts: { fast: true } }, + { id: "g1", api: "gamma" }, + ], +}); +mixed.add({ id: "a2", api: "alpha" }); +report(mixed); + +const beta: Built<"beta"> = build({ + name: "beta", + items: [ + { id: "b1", api: "beta", opts: { depth: 2 } }, + { id: "o1", api: "other", kind: "other", size: 1 }, + ], +}); +beta.add({ id: "o2", api: "other", kind: "other", size: 3 }); +report(beta); + +const custom = build({ name: "custom", items: [{ id: "c1", api: "custom" }] }); +custom.add({ id: "c2", api: "custom" }); +report(custom); + +const open = build({ name: "open", items: [{ id: "x1", api: "external" }] }); +report(open); +const first = open.chat()[0]; +console.log(first === undefined ? "none" : first.opts === undefined ? "no opts" : "opts"); diff --git a/tests/harness/corpus-costs/generic-conditional-member-bindings.ts.json b/tests/harness/corpus-costs/generic-conditional-member-bindings.ts.json new file mode 100644 index 000000000..f46d03f09 --- /dev/null +++ b/tests/harness/corpus-costs/generic-conditional-member-bindings.ts.json @@ -0,0 +1 @@ +{ "plain": 14775, "san": 16991 }