From 5bcabf9dd52a4901e505525cefaba04ab6ed56a8 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 07:49:13 -0700 Subject: [PATCH] Lower a finite-literal keyed read over a subset of the declared fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `filter[resourceType]` where the receiver is a record with several optional members and the key type is a finite union of literal keys (`"extensions" | "skills" | ...`) refused with SC1090 whenever the checker's answer type did not cover every declared field. That is the common settings-record shape: the key domain names four of the members and the rest have unrelated types. The key type is a finite domain, so the read now dispatches over exactly the fields it can name, comparing the key against each canonical property spelling. The answer is the join of the reachable fields' types — precisely the checker's answer type — and a key outside the domain reads undefined, as a missing property does. Nothing is emitted for a field the key type cannot name, so no widening to the full record leaks into downstream lowering. The dispatch is gated on the shape having no index signature, on the access not being a call (`o[k]()` keeps its fence for inherited methods), on the reachable fields being a proper subset, and on the join holding an undefined arm so a smuggled key has an answer. A key domain that names every field keeps the single keyed-read helper. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/element-access.ts | 162 ++++++++++++++++++ .../corpus/record-subset-keyed-read.ts.json | 6 + tests/corpus/record-subset-keyed-read.ts | 96 +++++++++++ .../record-subset-keyed-read.ts.json | 1 + 4 files changed, 265 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-subset-keyed-read.ts.json create mode 100644 tests/corpus/record-subset-keyed-read.ts create mode 100644 tests/harness/corpus-costs/record-subset-keyed-read.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts index 9bbe2ea0c..9836cd56d 100644 --- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -1,6 +1,7 @@ /* Element reads and writes (`a[i]`, `a[i] = v`), compound element updates, and keyed record reads. */ import { OBJECT_PROTOTYPE_MEMBERS } from "../../shape-inference/domain.js"; import { lowerUnionTupleRead } from "./union-fields.js"; +import { constituentTypes } from "../../ts7/checker.js"; import * as ts from "../../ts7/adapter.js"; import { lowerOptionalIndex, @@ -1448,6 +1449,40 @@ export function lowerRecordKeyRead( const read = lowerer.mapTypeOf(lowerer.typeOf(expr)); if (read !== null && read.kind === "union" && recordKeyResultOk(lowerer, shape, read)) declared = read; + else if (!isCalledAccess(expr)) { + // A key type that is a FINITE union of literal keys names a subset of + // the declared fields (`filter[resourceType]` over a settings record + // whose other members have their own types). The answer joins only + // the reachable fields' types — the checker's own answer type — and + // the read dispatches over those keys; a key the type system does not + // admit is a smuggled miss, which reads undefined as a missing + // property does (the join must hold the arm to say so). + const domain = literalKeyDomain(lowerer, keyNode); + const reachable = + domain === null ? [] : shape.fields.filter((field) => domain.includes(field.name)); + if (domain !== null && reachable.length > 0 && reachable.length < shape.fields.length) { + const join = joinedFieldType(lowerer, reachable); + if ( + join !== null && + join.kind === "union" && + lowerer.armTag(join.unionId, UNDEFINED_T) >= 0 && + recordKeyResultOk(lowerer, { ...shape, fields: reachable }, join) + ) { + return lowerFiniteKeyedRead( + lowerer, + obj, + shapeId, + shape, + domain, + key, + join, + loc, + includeUndefined, + expr, + ); + } + } + } } // Fields of different types answer a checked-dynamic value where nothing // typed the read: in JavaScript, and where the checker types it 'any' @@ -1561,6 +1596,133 @@ function recordKeyTypeLiteralText(lowerer: Lowerer, node: ts.Expression): string return null; } +/** The FINITE key domain a key type proves: the canonical property spelling + * of each string/number literal arm of a union-typed key, or of a key type + * that IS one literal. Null when the type keeps any arm that is not a + * literal (a widened string, a template, a symbol) — such a key can name + * any field, so no finite dispatch exists. */ +function literalKeyDomain(lowerer: Lowerer, node: ts.Expression): string[] | null { + let t: ts.Type = lowerer.typeOf(node); + if (t.flags & ts.TypeFlags.TypeParameter) { + const bound = lowerer.typeParamTsResolver(t); + if (!bound) return null; + t = bound; + } + const arms = (t.flags & ts.TypeFlags.Union) !== 0 ? constituentTypes(t) : [t]; + const domain: string[] = []; + for (const arm of arms) { + if (arm.isStringLiteralType()) { + domain.push(arm.value); + continue; + } + if (arm.isNumberLiteralType()) { + const n = arm.value; + if (!Number.isFinite(n) || n < 0) return null; + domain.push(String(n)); + continue; + } + return null; + } + return domain.length > 0 ? domain : null; +} + +/* The interned join of the given fields' types, deduped and sorted (the + * canonical union form). Null when an arm has no value representation (a + * bare 'void', a generator, a caught value) — the caller keeps its fence. */ +function joinedFieldType(lowerer: Lowerer, fields: readonly { type: IrType }[]): IrType | null { + const arms: IrType[] = []; + const seen = new Set(); + const push = (t: IrType): boolean => { + if (t.kind === "union") { + const def = lowerer.unions.get(t.unionId); + if (!def) return false; + return def.arms.every(push); + } + if (t.kind === "void" || t.kind === "generator" || t.kind === "caught") return false; + const key = typeKey(t); + if (!seen.has(key)) { + seen.add(key); + arms.push(t); + } + return true; + }; + for (const field of fields) if (!push(field.type)) return null; + if (arms.length === 0) return null; + arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + return arms.length === 1 ? arms[0]! : { kind: "union", unionId: lowerer.unions.intern(arms) }; +} + +/** `r[k]` where the key type is a FINITE union of literal keys naming a + * subset of `r`'s declared fields (`filter[resourceType]` over a settings + * record whose remaining members have their own types). The declared + * fields answer through a chain of key comparisons, each hit wrapped into + * the joined answer type; a key outside the domain reads undefined, as a + * missing property does. This is the per-key form of the keyed read the + * backend emits as a string switch — nothing is emitted for a field the + * key type cannot name, so the join stays exactly the checker's answer. */ +function lowerFiniteKeyedRead( + lowerer: Lowerer, + obj: IrExpr, + shapeId: string, + shape: IrRecordShape, + domain: readonly string[], + key: IrExpr, + answer: IrType & { kind: "union" }, + loc: SrcLoc, + includeUndefined: boolean, + expr: ts.ElementAccessExpression, +): IrExpr { + const stmts: IrStmt[] = []; + const settle = (value: IrExpr, name: string): IrExpr => { + if (isSafeToRepeat(value)) return value; + const temp = lowerer.declareHiddenLocal(name, value.type); + stmts.push({ kind: "varDecl", localId: temp.id, init: value, loc }); + return varRef(temp.id, temp.type, loc); + }; + // JavaScript evaluates the receiver before the key. + const receiver = settle(obj, "%keyedReceiver"); + const stableKey = settle(key, "%keyedKey"); + let result: IrExpr = { + kind: "unionWrap", + unionId: answer.unionId, + tag: lowerer.armTag(answer.unionId, UNDEFINED_T), + value: { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, + type: answer, + loc, + }; + for (let i = domain.length - 1; i >= 0; i--) { + const name = domain[i]!; + const field = shape.fields.find((f) => f.name === name); + if (!field) continue; + const get: IrExpr = { + kind: "recordGet", + obj: receiver, + shapeId, + field: name, + type: field.type, + loc, + }; + result = { + kind: "ternary", + cond: { + kind: "strEq", + negated: false, + left: stableKey, + right: strLit(name, loc), + type: BOOL, + loc, + }, + then: lowerer.coerceToExpected(get, answer), + else_: result, + type: answer, + loc, + }; + } + const read: IrExpr = + stmts.length === 0 ? result : { kind: "seqExpr", stmts, result, type: answer, loc }; + return includeUndefined ? read : lowerer.maybeNarrow(read, expr); +} + /** The literal index of a tuple access, or null when the expression isn't * a non-negative integer literal (parentheses tolerated — tsc's own * literal-index typing accepts them). */ diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-subset-keyed-read.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-subset-keyed-read.ts.json new file mode 100644 index 000000000..586520fa2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-subset-keyed-read.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-subset-keyed-read.ts" + ], + "diags": [] +} diff --git a/tests/corpus/record-subset-keyed-read.ts b/tests/corpus/record-subset-keyed-read.ts new file mode 100644 index 000000000..0c33ce8fa --- /dev/null +++ b/tests/corpus/record-subset-keyed-read.ts @@ -0,0 +1,96 @@ +// A runtime keyed read whose key type is a FINITE set of literal keys naming +// a subset of the record's declared fields (`filter[resourceType]` over a +// settings record whose remaining members have their own types). The answer +// joins the reachable fields' types — the checker's own answer type — and a +// key the type system does not admit reads undefined, as a missing property +// does. + +type ResourceType = "extensions" | "skills" | "prompts" | "themes"; + +interface PackageFilter { + autoload?: boolean; + extensions?: string[]; + prompts?: string[]; + skills?: string[]; + themes?: string[]; +} + +const RESOURCE_TYPES: ResourceType[] = ["extensions", "skills", "prompts", "themes"]; + +function list(filter: PackageFilter): void { + for (const resourceType of RESOURCE_TYPES) { + const patterns = (filter[resourceType] ?? []) as string[]; + console.log(resourceType, patterns.join(",")); + } +} +list({ autoload: true, extensions: [".ts", ".tsx"], skills: ["a"] }); + +// The same read behind a call, so the key is no identifier. +function filterOf(): PackageFilter { + return { prompts: ["p"] }; +} +function first(key: () => ResourceType): string[] { + return filterOf()[key()] ?? []; +} +console.log(first(() => "prompts"), first(() => "themes")); + +// Numeric-literal keys name mapped fields by their canonical spelling. +interface Counts { + 1?: number; + 2?: string; + 3?: boolean; +} +type Slot = 1 | 2; +function slot(counts: Counts, index: Slot): number | string | undefined { + return counts[index]; +} +const counts: Counts = { 1: 10, 2: "two", 3: true }; +for (const index of [1, 2] as Slot[]) { + console.log(index, slot(counts, index)); +} + +// A key domain that covers EVERY field keeps the single keyed-read helper. +interface Nested { + enabled?: boolean; + limit?: number; +} +interface Settings { + auto?: boolean; + list?: string[]; + nested?: Nested; +} +function anyKey(settings: Settings, key: keyof Settings): boolean | string[] | Nested | undefined { + return settings[key]; +} +const settings: Settings = { auto: true, list: ["x"], nested: { limit: 2 } }; +const nested = anyKey(settings, "nested"); +console.log( + anyKey(settings, "auto"), + anyKey(settings, "list"), + nested !== undefined && !Array.isArray(nested) && typeof nested !== "boolean" + ? nested.limit + : "none", +); + +// An index-signature record answers its slot type for a union key. +interface Bag { + [key: string]: string | number | undefined; + known: string; +} +function bagRead(bag: Bag, key: "known" | "other"): string | number | undefined { + return bag[key]; +} +const bag: Bag = { known: "k", other: 7 }; +console.log(bagRead(bag, "known"), bagRead(bag, "other")); + +// A reachable field that is itself a record. +interface Spec { + headers?: { [key: string]: string }; + port: number; + name: string; +} +function headersOf(spec: Spec, key: "headers"): { [key: string]: string } | undefined { + return spec[key]; +} +const headers = headersOf({ headers: { a: "b" }, port: 1, name: "n" }, "headers"); +console.log(headers === undefined ? "missing" : headers["a"]); diff --git a/tests/harness/corpus-costs/record-subset-keyed-read.ts.json b/tests/harness/corpus-costs/record-subset-keyed-read.ts.json new file mode 100644 index 000000000..cb54e203d --- /dev/null +++ b/tests/harness/corpus-costs/record-subset-keyed-read.ts.json @@ -0,0 +1 @@ +{ "plain": 4000, "san": 2400 }