Dispatch a keyof read whose member is unknown per declared key

`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 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-29 05:38:11 -07:00
co-authored by Claude Opus 5.5
parent 3328ed2aa7
commit e198a9eec2
4 changed files with 61 additions and 0 deletions
@@ -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",
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8523-settings-keyof-dynamic-read.ts"
],
"diags": []
}
@@ -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));
}
@@ -0,0 +1 @@
{ "plain": 1300, "san": 2700 }