Read T[keyof T] as unknown for T bound to an unknown-valued record

Inside a generic body Object.entries(t) over T extends Record<string,
unknown> types its values 'keyof T extends never ? unknown : T[keyof T]'.
An instance whose T maps to the checked-dynamic value now reads both
branches as unknown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-19 21:01:37 -07:00
co-authored by Claude Opus 5.5
parent c17eec0261
commit c208973d75
3 changed files with 56 additions and 2 deletions
+17 -2
View File
@@ -5139,11 +5139,15 @@ function mapGenericIndexedAccess(type: ts.Type, ctx: TypeMapperCtx): IrType | nu
const obj = type.getObjectType();
if (obj === undefined || !(obj.flags & ts.TypeFlags.TypeParameter)) return null;
const bound = resolveTypeParam(obj);
const idx = type.getIndexType();
if (idx === undefined) return null;
// `T[keyof T]` over a T bound to an unknown-valued record view
// (`Record<string, unknown>` maps to the checked-dynamic value, see
// mapRecordType): every member reads as unknown.
if (bound?.kind === "dyn" && idx.isIndexType() && idx.getTarget() === obj) return DYN;
if (!bound || bound.kind !== "record") return null;
const shape = shapes.get(bound.shapeId);
if (!shape || shape.tuple) return null;
const idx = type.getIndexType();
if (idx === undefined) return null;
let covered: IrType[] | null = null;
if (idx.isStringLiteralType()) {
const f = shape.fields.find((x) => x.name === idx.value);
@@ -5199,6 +5203,17 @@ function mapBoundKeyofNeverConditional(type: ts.Type, ctx: TypeMapperCtx): IrTyp
const target = check.getTarget();
if (!target || !(target.flags & ts.TypeFlags.TypeParameter)) return null;
const bound = resolveTypeParam(target);
// A T bound to the checked-dynamic value (`Record<string, unknown>`, an
// unknown-valued record view) has no static key set to decide the
// branch, but both branches of the lib's `keyof T extends never ?
// unknown : T[keyof T]` read as unknown there: one answer either way.
if (bound?.kind === "dyn") {
const whenEmpty = type.getTrueType();
const otherwise = type.getFalseType();
const a = whenEmpty ? mapType(whenEmpty, ctx) : null;
const b = otherwise ? mapType(otherwise, ctx) : null;
return a && b && typeEquals(a, b) ? a : null;
}
if (bound?.kind !== "record") return null;
const shape = shapes.get(bound.shapeId);
if (!shape || shape.tuple) return null;
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6468-generic-unknown-record-entries.ts"
],
"diags": []
}
@@ -0,0 +1,33 @@
// Inside a generic body, `Object.entries(tags)` over `T extends
// Record<string, unknown>` reads `[string, T[keyof T]]` elements. An
// instance whose T is the unknown-valued record view reads every member
// as unknown; a record instance reads the union of its member types.
function upper(text: string): string {
return text.toUpperCase();
}
function scrub(text: string): string {
return text.replace(/secret/g, "***");
}
function describe<T extends Record<string, unknown>>(tags: T): string[] {
return Object.entries(tags).map(
([key, value]) => `${key}=${typeof value === "string" ? upper(value) : String(value)}`,
);
}
function redact<T extends Record<string, unknown>>(tags: T): T {
return Object.fromEntries(
Object.entries(tags).map(([key, value]) => [key, typeof value === "string" ? scrub(value) : value]),
) as T;
}
function flatten(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : { value };
}
console.log(describe(flatten({ b: 1, a: "x", c: true })));
console.log(describe(flatten(5)));
console.log(describe({ name: "n", size: 3 }));
const cleaned = redact(flatten({ token: "secret", count: 2 }));
console.log(cleaned, Object.keys(cleaned));