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 }