From e198a9eec235a36fdca3b954c27c4eedd249939a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:04:06 -0700 Subject: [PATCH] Dispatch a `keyof` read whose member is `unknown` per declared key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `settings[key]` over a settings record with an `unknown` member (`type: unknown`) refuses even though every field reads through a checked-dynamic slot: the checker's answer collapses to `unknown` because the union of the field types absorbs into it, so there is no typed join for the keyed read to dispatch into. Recognize that shape — the read's type is `unknown`, the shape has no index signature, and every declared field's value is checked-dynamic representable — and dispatch per declared key answering a checked- dynamic read. The key type is the record's own `keyof`, so the answer is always present. Corpus 8523 pins the read differentially. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/element-access.ts | 14 +++++++ .../8523-settings-keyof-dynamic-read.ts.json | 6 +++ .../8523-settings-keyof-dynamic-read.ts | 40 +++++++++++++++++++ .../8523-settings-keyof-dynamic-read.ts.json | 1 + 4 files changed, 61 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8523-settings-keyof-dynamic-read.ts.json create mode 100644 tests/corpus/8523-settings-keyof-dynamic-read.ts create mode 100644 tests/harness/corpus-costs/8523-settings-keyof-dynamic-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 5cb9776f6d..1ce873853d 100644 --- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -1534,6 +1534,20 @@ export function lowerRecordKeyRead( recordKeyResultOk(lowerer, shape, DYN) ) declared = DYN; + // A `keyof` read over a record that holds an 'unknown' member: the + // checker's answer collapses to 'unknown' (the union of every field + // type absorbs into it), so there is no typed join to dispatch into. + // Each declared field still reads through its checked-dynamic slot — + // the very value the checker's 'unknown' can hold — so dispatch per + // declared key and answer a checked-dynamic read. The key type is the + // record's own `keyof`, so the answer is present whenever the read is. + if ( + !declared && + (lowerer.typeOf(expr).flags & ts.TypeFlags.Unknown) !== 0 && + !shape.indexValue && + recordKeyResultOk(lowerer, shape, DYN) + ) + declared = DYN; if (!declared) { lowerer.unsupported( "SC1090", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8523-settings-keyof-dynamic-read.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8523-settings-keyof-dynamic-read.ts.json new file mode 100644 index 0000000000..e42bdab216 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8523-settings-keyof-dynamic-read.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8523-settings-keyof-dynamic-read.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8523-settings-keyof-dynamic-read.ts b/tests/corpus/8523-settings-keyof-dynamic-read.ts new file mode 100644 index 0000000000..d0460d6dff --- /dev/null +++ b/tests/corpus/8523-settings-keyof-dynamic-read.ts @@ -0,0 +1,40 @@ +// A `keyof` read over a record that holds an `unknown` member: the +// checker's answer collapses to `unknown` (the union of every field type +// absorbs into it), so there is no typed join. The read dispatches per +// declared key through the checked-dynamic slot instead. +interface Retry { + baseDelayMs?: number; + enabled?: boolean; +} +interface Settings { + $schema?: string; + enabledModels?: string[]; + branchSummary: unknown; + fullscreenWheelScrollLines?: number | string; + modelThinkingLevels?: { [key: string]: string }; + retry?: Retry; + outputPad?: number; +} +function read(settings: Settings, key: keyof Settings): unknown { + return settings[key]; +} +const settings: Settings = { + branchSummary: { note: "x", count: 2 }, + outputPad: 4, + enabledModels: ["m"], + fullscreenWheelScrollLines: "wide", + modelThinkingLevels: { high: "big" }, + retry: { enabled: true }, +}; +const keys: (keyof Settings)[] = [ + "outputPad", + "enabledModels", + "branchSummary", + "retry", + "$schema", + "fullscreenWheelScrollLines", +]; +for (const key of keys) { + const value = read(settings, key); + console.log(key, typeof value, value === undefined ? "absent" : JSON.stringify(value)); +} diff --git a/tests/harness/corpus-costs/8523-settings-keyof-dynamic-read.ts.json b/tests/harness/corpus-costs/8523-settings-keyof-dynamic-read.ts.json new file mode 100644 index 0000000000..10b67e419b --- /dev/null +++ b/tests/harness/corpus-costs/8523-settings-keyof-dynamic-read.ts.json @@ -0,0 +1 @@ +{ "plain": 1300, "san": 2700 }