typescript

Why would some arbitrary key be "missing" from a Record<string,any>?


Consider:

// valid
let a: Record<string, any> = {data: "hello"};

// Property 'data' is missing in type 'Record<string, any>' but required in type '{ data: string; }'
let b: {data: string} = a;

Isn't the point of a Record just to specify that something has keys type K and values type T? How does asserting that a particular record has certain keys violate this?

I understand I could make the property optional:

// valid
let b: {data?: string} = a;

Why should a more specific {stringkeyname: any} type not be assumed to be compatible with Record<string, any>?


Solution

  • While the Record<K, V> utility type does roughly mean "an object type with keys of type K and values of type V", it doesn't always mean that every key of type K will be present. Record<K, V> is implemented as the mapped type {[P in K]: V}, and when the key type you're mapping over is a wide type like string, you get a resulting type with an index signature like {[k: string]: V}. (Contrast to a union of literal types like "a" | "b" | "c", where Record<"a" | "b" | "c", V> ends up becoming the equivalent of {a: V, b: V, c: V}). So let's compare an index signature type like { [k: string]: string } with an object type with known keys like { data: string }:

    declare let b: { data: string };
    declare let a: { [k: string]: string };
    a = b; // okay
    b = a; // error
    // Property 'data' is missing in type '{ [k: string]: string; }' 
    // but required in type '{ data: string; }'
    

    This is what you are seeing: the assignment of { data: string } to { [k: string]: string } succeeds, but the assignment of { [k: string]: string } to { data: string } fails, and you're wondering why.

    The answer is a little tricky because TypeScript's type system isn't 100% consistent. Some types behave differently depending on the operations you perform on them. (Question: wait, shouldn't TypeScript be 100% type safe? Answer: no, that's not a goal of the language (non-goal #3); instead, there's a tradeoff between safety and usability.)

    See this comment on microsoft/TypeScript#32987 and subsequent comments for an authoritative answer.


    Conceptually an index signature type like { [k: string]: V } means "every property of the object whose key is of type string has a value of type V". It does not mean that "every possible key of type string exists as a key of the object, and the value for each such key is of type V". You can't really have a plain object with all possible string-valued keys (well, you can simulate such an exotic beast with a Proxy, but that's a digression from what we're talking about). An index signature like declare const obj: {[k: string]: V} is meant to let you loop through its properties like for (const k in obj) { obj[k] } and you know that obj[k] will be of type V. It's not really meant for you to index into the object with random keys like obj.foobarbazqux and expect to see a V there. Indeed you are quite likely to really see no property at all, and therefore read undefined. There is a compiler option called --noUncheckedIndexedAccess which will give you V | undefined when you read from such types, but most people don't use it because indexing with random keys really is not the intended use case of an index signature, and it is obnoxious to keep checking for undefined when you loop over properties.

    So that's what an index signature is: it is a contract for the relationship of keys to values, but not a guarantee of the presence of any particular key.


    On the other hand, an object type like {data: V} is telling you that there is definitely a property with the key "data" and the property value is of type V. You are supposed to index into it with its known key like obj.data, and you're not really supposed to loop through its properties with a for...in loop.

    Technically the type {data: V} doesn't say anything about other properties. So it's possible that a value of type {data: V} might have all kinds of other properties, whose keys could be anything except data and whose values could be anything at all. But when you create a type like {data: V} from an object literal, TypeScript will tend to treat it as if it has no other properties. This is excess property checking and another place where TypeScript isn't 100% consistent.


    So, what happens when you try to assign one to the other? Well, the assignment of {data: V} to {[k: string]: V} succeeds. The type {data: V} is given an implicit index signature, and since every known string-keyed property of {data: V} is assignable to V, it is allowed. This is very useful, but technically unsafe if there turns out to be an excess property. In practice, excess properties happen rarely enough that this assignment is allowed.

    On the other hand, the assignment of {[k: string]: V} to {data: V} fails. It is actually quite unlikely that an arbitrary object of type {[k: string]: V} will happen to have the specific known keys of an object type like {data: V}. You can make an example like where it is, like const rec: Record<string, string> = {data: "abc"}; const obj: {data: string} = rec, but TypeScript only knows about the annotated type of rec so it has lost any information about data, and thus treats it as if you've written const rec: Record<string, string> = {oops: "abc"}; const obj: {data: string} = rec, which you can hopefully see is unsafe. Once you have an object of type {data: string} you're going to try to access its data property, and the chances are low that a Record<string, string> has one of those. So the unsafe event is quite likely, and thus the assignment is disallowed.


    So that's the answer, although it's riddled with inconsistencies. Yes, technically if --noUncheckedIndexedAccess is off, you can take a value rec of type Record<string, string> and write rec.foobarbazqux.toUpperCase() and you won't get a compiler error even though it's a good bet that there will be a runtime error. And yes, technically that's essentially the same operation as const obj: {foobarbazqux: string} = rec; obj.foobarbazqux.toUpperCase(); which does give you a compiler error. The difference is that one use case is rare (indexing into index signature types with arbitrary unchecked keys) and the other is common (indexing into regular object types with their known keys), and TypeScript's rules are meant to support the common cases more than the uncommon ones. When type safety conflicts with ease of use, TypeScript often gives up some type safety.

    Playground link to code