2

Consider this:

type N = never;
type A = 'A';
type B = 'A' | 'B';
type S = string;

type RN = Record<N, string>;
type RA = Record<A, string>;
type RB = Record<B, string>;
type RS = Record<S, string>;

declare let n : N;
declare let a : A;
declare let b : B;
declare let s : S;

s = b;
b = a;
a = n;

declare let rn : RN;
declare let ra : RA;
declare let rb : RB;
declare let rs : RS;

rn = rs;
rs = rn;

rs = ra;
ra = rb;

Let < be the subtype operator. Obviously, N < A < B < S because n is assignable to a is assignable to b is assignable to s.

So, I would expect RS < RB < RA < RN.

However, from the example you see that RB < RA < RS because rb is assignable to ra is assignable to rs. Moreover, RS and RN seem to be equivalent types.

I would assume that string can be seen as the union type of all string literal types. So actually RS should be equal tonever since it’s impossible to have an object with properties for all possible string literals that exist (taking infinite space). Call this the complete object.

However it looks like RS is actually equivalent to the empty (RN) and not complete object.

Why is string behaving like never in Record?

user3612643
  • 5,096
  • 7
  • 34
  • 55

2 Answers2

3

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.

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Thanks a lot for your detailed answer! You are right about the complete object. Of course it can exist since overriding the index getter and returning something mimics a complete object. – user3612643 Oct 17 '21 at 07:46
2

I would assume that string can be seen as the union type of all string literal types. So actually RC should be equal to never since it’s impossible to have an object with properties for all possible string literals that exist (taking infinite space).

This is the crux of the issue. A type Record<K, V>, and in general any type with an index signature, is supposed to mean objects whose keys are the values of the type K, such that if obj: Record<K, V> and k: K then obj[k] is of type V. If K is a type with infinitely many values, this is impossible in practice for the reason you wrote. So if we're being totally formal, then it's not possible to construct a value of type Record<K, V>,* so if Typescript were totally sound then Record<string, V>, and index signatures, would not be useful.

But Typescript is not totally sound, nor is it meant to be:

TypeScript’s type system allows certain operations that can’t be known at compile-time to be safe. When a type system has this property, it is said to not be “sound”. The places where TypeScript allows unsound behavior were carefully considered, and throughout this document we’ll explain where these happen and the motivating scenarios behind them.

So, index signatures and Record work the way they do because it is useful to programmers who write Javascript code which assumes objects behave that way. Real Javascript code often uses objects as dictionaries, and often uses keys which are known to be present without handling the case where the key is not present.

For a sound alternative to Record<string, V>, you should write something like {[k in string]?: V} so that the type system explicitly knows that not all possible keys may be present in the object. In this case, when you access obj[k] it will have type V | undefined instead of V, and you will have to handle that possibility in your code (e.g. by narrowing with an if statement to check if the value is undefined).


*For technical reasons, that's not the same as Record<K, V> being equal to never when K is infinite. It is semantically entailed in the sense that those two types have the same value sets, but not syntactically entailed in the sense that Typescript treats them as assignable to each other (because it doesn't). Typescript has no rule to reduce Record<K, V> to never when K is infinite; I don't think Typescript keeps track of whether types are infinite in the first place.

kaya3
  • 47,440
  • 4
  • 68
  • 97
  • Sorry, I modified my question while you answered and added never too – user3612643 Oct 16 '21 at 14:06
  • I don't think the reasoning here is quite right. `Record` doesn't necessarily have an index signature; it's a mapped type and only has index signatures when the key type includes nonliterals like `string` or now `\`x${string}\``. Index signatures mean *if* there is a key of the index type, *then* its value has the value type. So `{[k: string]: number}` does not mean "all `string` keys exist and have `number` values" but "for each `string` key that exists it has a `number` value". It's almost like index signatures are *optional* properties (see `--noUncheckedIndexedAccess`) – jcalz Oct 16 '21 at 14:28
  • For example, `Record<"A" | "B", string>` has no index signature, so the `"A"` and `"B"` keys are required. But `Record` has a `string` index signature, which does not require any keys at all. I can probably find various github issue comments by ahejlsberg and other TS team members corroborating this view of index signatures as constraints on values when keys happen to be present, if you think otherwise. – jcalz Oct 16 '21 at 14:33
  • @jcalz can you point me to the typescript docs detailing that out? – user3612643 Oct 16 '21 at 14:36
  • The documentation for [index signatures](https://www.typescriptlang.org/docs/handbook/2/objects.html#index-signatures) says "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 such properties are *possible* and not required. – jcalz Oct 16 '21 at 14:51
  • And the documentation for [the `--noUncheckedIndexedAccess` flag](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-1.html#checked-indexed-accesses---nouncheckedindexedaccess) describes how you can opt to have index signature property reads include `undefined` for the very reason that the index signature does not imply that every possible property is actually present. This flag is not used by default because it breaks real world code that loops through arrays or objects by index. – jcalz Oct 16 '21 at 14:55
  • @jcalz okay I tried a bit and figured out together with noUncheckedIndexAccess the behavior at least becomes consistent when accessing values. – user3612643 Oct 16 '21 at 14:55
  • Ouch, furthermore the suggested `{[k: string]?: V}` is [invalid syntax](https://tsplay.dev/wO8O6N); there is no optional modifier for index signatures, as they are already optional (in terms of the set of keys they represent) – jcalz Oct 16 '21 at 14:57
  • If @kaya3 is willing to update this answer then great, otherwise I'll probably write up my own to clear this stuff up. – jcalz Oct 16 '21 at 14:59
  • @jcalz Thanks, for some reason I keep forgetting that `{[k: string]?: V}` is not valid. Re: "`Record` doesn't necessarily have an index signature", I didn't mean to imply that it does, as far as I know mapped types and index signatures are just separate things. It sounds like you understand this better than me, though, so feel free to write an answer and I expect I'll upvote it. – kaya3 Oct 16 '21 at 15:27
  • 2
    What l learned today: 1. Mapped types imply index signatures if the key is a non-literal-type, but literal-type keys imply required properties. 2. Index signatures are optional from the type system, but don’t widen the value type with undefined by default. But that can be enforced with noUncheckedIndexAccess. Correct @jcalz ? – user3612643 Oct 16 '21 at 15:56
  • Pretty much, yeah. I'm happy to write up an answer but I'm not sure when I'll find the time today. – jcalz Oct 16 '21 at 16:03
  • @jcalz you are always helpful when I enter the dark corners of typescript :-) – user3612643 Oct 16 '21 at 16:16