TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

`noUncheckedIndexedAccess` should forbid unsound `Record<string, string>` → `Record<"k", string>` coercion

Open andersk opened this issue 1 month ago • 6 comments

🔎 Search Terms

coercion, noUncheckedIndexedAccess, Record, unsound

🕗 Version & Regression Information

  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about noUncheckedIndexedAccess

⏯ Playground Link

https://www.typescriptlang.org/play/?noUncheckedIndexedAccess=true&ts=5.9.3#code/DYUwLgBAhgXBBKIDGB7ATgEwDwGcxoEsA7AcwBoI9DSA+CAXggG8BfAbgChRIAjORVJiwAiANbCKVYiTqMobCAHpFEAK5EcKdRi7gIPUXCmkG+gHSjOBs2BQAZFAHcQaAMJQcIABQBKBcogAMygCYBxoSDR1MAIAWxAOIA

💻 Code

let a: Record<string, string> = {};
let b: Record<"k", string> = a; // unsound
let bk: string = b.k;
bk.toLowerCase(); // fails at runtime

🙁 Actual behavior

No TypeScript errors.

🙂 Expected behavior

By enabling noUncheckedIndexedAccess, I’ve opted in to stricter errors that prevent mistakes where a potentially undefined record element is assumed to be defined. The coercion from a: Record<string, string> to Record<"k", string> is such a mistake, since it assumes that a.k is defined. So TypeScript should forbid this coercion when noUncheckedIndexedAccess is enabled.

Additional information about the issue

noUncheckedIndexedAccess already causes TypeScript to correctly reject the equivalent unsound coercion Record<string, string>{k: string}.

andersk avatar Nov 23 '25 08:11 andersk

This seems spiritually similar to my issue that got closed as not planned: https://github.com/microsoft/TypeScript/issues/61782 which was closed as a dupe of Exact Types: https://github.com/microsoft/TypeScript/issues/12936

That said, Record<string, string> is essentially saying "This record can map any string to another string". I have found Partial Records to often be more appropriate:

let a: Partial<Record<string, string>> = {};
let b: Record<"k", string> = a; // Error: Property 'k' is missing in type 'Partial<Record<string, string>>' but required in type 'Record<"k", string>'.

tylerc avatar Nov 24 '25 16:11 tylerc

No, this issue is different.

Your type Record<`id_${string}`, { foo: 'bar' }> is a partial record (as it must, otherwise it’d be infinitely large), and {} is a valid inhabitant of it, as is { mapping: {}, warnings: [] } (subtyping allows extra properties). To prohibit that, a new kind of type would be needed; that’s what exact types would be.

My type Record<"k", string> is a total record (k is a required property), and TypeScript knows this: it already correctly prohibits assigning {} to Record<"k", string>. My type Record<string, string> is a partial record, and when noUncheckedIndexedAccess is enabled, TypeScript knows this: it already correctly prohibits coercing Record<string, string>{k: string} or directly assigning .k to string.

All I’m asking here is that TypeScript apply the definitions and knowledge it already has: with noUncheckedIndexedAccess it should consistently treat Record<string, string> as partial and forbid coercing it to a nonempty total record type as well.

andersk avatar Nov 24 '25 19:11 andersk

This would be an enormous breaking change to anyone who just wanted their explicit array access to be bounds-checked, which is what that flag is for.

RyanCavanaugh avatar Nov 24 '25 20:11 RyanCavanaugh

I only use record types with the value type including undefined: Record<string, string|undefined>. Record works way better (read: stricter) that way.

jendrikw avatar Nov 24 '25 20:11 jendrikw

Arrays may be the common case (simply because people write more arrays than records), but the noUncheckedIndexedAccess documentation doesn’t say anything specific to arrays.

If you’d like a test case with an out-of-bounds array access, I give you this.

I totally agree that code relying on an unsound quirk of Record<string, string> that would be disallowed by Record<string, string | undefined> is bad code. That’s why I enabled noUncheckedIndexedAccess, and why I would like TypeScript to help me find that bad code.

andersk avatar Nov 24 '25 21:11 andersk

I might be confused but this really looks like a duplicate of #29698, and I don't think it has much of anything to do with noUncheckedIndexedAccess:

let a: Record<string, string> = {};
let x: { [P in "k"]: string } = a; // error
let y: { k: string } = a; // error
let b: Record<"k", string> = a; // no error?!

Playground link

The types of x and y are structurally identical to the type of b. The errors on x and y show that TypeScript already behaves as desired when assigning from index signatures. The real problem here is that b has no error, even though it's the "same" assignment. That's happening because TypeScript takes a shortcut when comparing Record<K1, V> to Record<K2, V> using the assumption that Record<K, V> is contravariant in K, which it mostly is except for when it isn't. This is #29698.

If #29698 is ever fixed then this problem should just go away without touching noUncheckedIndexedAccess, right?

jcalz avatar Dec 08 '25 15:12 jcalz