diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts index 3ca1471ae..045bebdf4 100644 --- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -1321,6 +1321,15 @@ export function lowerRecordKeyRead( shape.fields.every((f) => typeEquals(f.type, shape.fields[0]!.type)) ) { declared = shape.fields[0]!.type; + } else if (shape.fields.length > 0) { + // Fields of different types (`row[key]` with `key: keyof Row`): the + // checker types the read as the union of the field types the key can + // name. When every declared field surfaces in that union (an arm, or a + // union that widens into it), the per-field dispatch wraps each hit + // into its arm. + const read = lowerer.mapTypeOf(lowerer.typeOf(expr)); + if (read !== null && read.kind === "union" && recordKeyResultOk(lowerer, shape, read)) + declared = read; } // Fields of different types answer a checked-dynamic value where nothing // typed the read: in JavaScript, and where the checker types it 'any' diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7400-record-keyof-union-read.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7400-record-keyof-union-read.ts.json new file mode 100644 index 000000000..4f2a4c712 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7400-record-keyof-union-read.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7400-record-keyof-union-read.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7400-record-keyof-union-read.ts b/tests/corpus/7400-record-keyof-union-read.ts new file mode 100644 index 000000000..4a62a5067 --- /dev/null +++ b/tests/corpus/7400-record-keyof-union-read.ts @@ -0,0 +1,41 @@ +// Keyed reads of records whose fields have different types, with a key +// typed as `keyof` the record: the read is the union of the field types. +interface Book { + readonly id: string; + readonly title: string; + readonly year: number; + readonly tags: readonly string[]; + readonly available: boolean; +} +type BookPatch = Partial>; + +function changedKeys(current: Book, patch: BookPatch): string[] { + const changed: string[] = []; + for (const key of Object.keys(patch) as (keyof BookPatch)[]) { + const before = current[key]; + const after = patch[key]; + if (after === undefined) continue; + if (Array.isArray(before) && Array.isArray(after)) { + if (before.join("|") !== after.join("|")) changed.push(key); + } else if (before !== after) changed.push(key); + } + return changed; +} + +function describe(book: Book, key: keyof Book): string { + const value = book[key]; + if (typeof value === "string") return `${key}: string ${value}`; + if (typeof value === "number") return `${key}: number ${value.toFixed(1)}`; + if (typeof value === "boolean") return `${key}: boolean ${value ? "yes" : "no"}`; + return `${key}: list of ${value.length} [${value.join(", ")}]`; +} + +const book: Book = { id: "b1", title: "Atlas", year: 1999, tags: ["maps", "travel"], available: true }; +console.log(changedKeys(book, { title: "Atlas", year: 2001 })); +console.log(changedKeys(book, { tags: ["maps", "travel"], available: false })); +console.log(changedKeys(book, { tags: ["maps"], title: "Atlas II", year: undefined })); +for (const key of ["id", "title", "year", "tags", "available"] as const) console.log(describe(book, key)); +const keys: (keyof Book)[] = ["year", "tags"]; +const values = keys.map((k) => book[k]); +console.log(values, values.length); +console.log(JSON.stringify(Object.fromEntries(keys.map((k) => [k, book[k]])))); diff --git a/tests/harness/corpus-costs/7400-record-keyof-union-read.ts.json b/tests/harness/corpus-costs/7400-record-keyof-union-read.ts.json new file mode 100644 index 000000000..9b72eaf8d --- /dev/null +++ b/tests/harness/corpus-costs/7400-record-keyof-union-read.ts.json @@ -0,0 +1 @@ +{ "plain": 2600, "san": 20500 }