Mapped types like the Record<K, V>
utility type map string
and number
literal keys to individual properties, so Record<"A" | "B", string>
is equivalent to {a: string; b: string}
.
But keys of wide, non-literal types like string
itself, or number
, or pattern template literal types like `foo${string}`
(as implemented in microsoft/TypeScript#40598) are mapped to index signatures. From the documentation for index signatures:
Sometimes you don’t know all the names of a type’s properties ahead of time, but you do know the shape of the values. In those cases you can use an index signature to describe the types of possible values.
So index signatures do not really represent "complete objects" with all possible keys of the relevant type, like an infinite intersection of all single-key objects {a: string} & {b: string} & {c: string} & ... & {foo: string} & ... {blahblah: string} & ...
.
(Aside: you said a complete object would be equivalent to never
because it's not possible. But that's not really accurate. A Proxy
object could easily be made to conform to this type. Even if it were not possible in JavaScript, it wouldn't be obvious that you'd want a type system to treat it as if it were never
, without having some sort of explicit axiom about infinity, and then you'd have to figure out how to do that without prohibiting recursive data types.)
Anyway, index signatures are more like constraints on properties. An index signature of the form {[k: IndexType]: ValType}
means "if the object has a property key of type IndexType
, then such a property will have a value of type ValType
". In some sense, it's more like the infinite intersection of all single-key objects with optional properties, like {a?: string} & {b?: string} & {c?: string} & ... & {foo?: string} & ... {blahblah?: string} & ...
Of course it's more complicated than that, since the compiler has not traditionally treated index signatures and optional properties the same.
Before TypeScript 4.1, index signatures would always let you read properties and get a value even though I just got finished explaining how they are more like optional properties. There were lots of complaints about this, and so TypeScript 4.1 introduced the --noUncheckedIndexedAccess
compiler flag, which added undefined
to the domain of index signature property values when reading, but not when writing. It's not enabled by default, even with --strict
, because while it's more type safe, it turns out to be annoying in any scenario where people index through arrays or objects... code like for (let i=0; i<arr.length; i++) {arr[i]}
or Object.keys(obj).forEach(k => obj[k])
should technically show arr[i]
and obj[k]
as being possibly undefined
, at least without having a way to track the identity of i
and k
instead of just the type.
Before TypeScript 4.4, optional properties were treated as having undefined
as part of their domain both when reading and writing. People complained about that a lot too, so TypeScript 4.4 introduced the --exactOptionalPropertyTypes
compiler flag which preserved undefined
on reads, but rejects writing to a property with undefined
. This is also not included with --strict
, since something like foo.bar = foo.bar
is now considered an error if bar
is optional.
If you enable both of those compiler flags, then index signatures and optional properties have similar behavior, although I'm sure more edge cases exist.
Anyway... Record<string, string>
is equivalent to {[k: string]: string}
) while Record<never, string>
is equivalent to the empty object type {}
. These are not identical types, but they are mutually compatible due to rules having to do with implicit index signatures as implemented in microsoft/TypeScript#7029.
There's a lot to unpack there too, and one could go on for quite a while about weak type detection, excess property checking, and the interaction between index signatures and interface
types (see microsoft/TypeScript#15300). I'm going to stop now, though, since this answer is already long enough.