SystemParadox
SystemParadox

Reputation: 8666

Typescript object indexes

I'm getting on pretty well with Typescript overall, but I keep bumping into this.

Say I have a type which can be one of a selection of strings:

type ResourceConstant = 'A' | 'B' | 'C';

Now I want to create an object to hold the quantity of each resource (so a mapping of ResourceConstant to number):

let x: { [key: ResourceConstant]: number } = {};

>>> error TS1337: An index signature parameter type cannot be a union type. Consider using a mapped object type instead.

So what is a "mapped object type"? I've found some information about "mapped types", but it's not clear how they related to this issue at all. Maybe they mean I should use Record:

let x: Record<ResourceConstant, number> = {};

>>> error TS2740: Type '{}' is missing the following properties from type 'Record<ResourceConstant, number>': U, L, K, Z, and 80 more.

Ok so it needs Partial:

let x: Partial<Record<ResourceConstant, number>> = {};

This works, but it's really horrible.

Anyway, let's try to use this somewhere:

for (let res in x) {
    terminal.send(res, x[res]);
}

>>> error TS2345: Argument of type 'string' is not assignable to parameter of type 'ResourceConstant'.

Ok, so it's lost the type information because objects are always indexed by strings. Fair enough, I'll just tell TypeScript that this is definitely a ResourceConstant:

for (let res: ResourceConstant in x) {

>>> error TS2404: The left-hand side of a 'for...in' statement cannot use a type annotation.

Maybe I can use as to force the type:

for (let res as ResourceConstant in x) {

>>> error TS1005: ',' expected

Or cast with the <> syntax?

for (let res<ResourceConstant> in x) {

>>> error TS1005: ';' expected

Nope. It seems I've got to create a second intermediate variable to force the type:

for (let res in x) {
    let res2 = res as ResourceConstant;
    terminal.send(res2, x[res2]);
}

This works, but it's also horrible.

What a mess. I know the correct answer is that I should be using new Map instead of {}, and that's all well and good, but this is JS - for better or worse we're used to using objects for this kind of thing. Most importantly, what if I'm annotating an existing codebase? Surely I shouldn't have to change it all to Map just for Typescript's benefit?

Why does it seem to be so hard to work with plain objects? What am I missing?

Upvotes: 5

Views: 5197

Answers (1)

jcalz
jcalz

Reputation: 330571

There was some work done in microsoft/TypeScript#26797 on allowing index signatures with arbitrary property-key types. It's unfortunately stalled since it's not clear how to deal with a mismatch between mapped type and index signature behavior. Index signatures, as currently implemented, have different and more unsound behavior than mapped types. You noticed this by assuming that you could assign {} to a mapped type whose keys are ResourceConstant, and by being told no because the properties are missing. Index signatures currently don't care if properties are missing, which is convenient but unsafe. Some work would need to be done to make mapped types and index signatures more compatible in order to proceed. Maybe the upcoming pedantic index signatures --noUncheckedIndexedAccess feature will unblock this? For now, you have to use mapped types.

Converting from an index signature to a mapped type is easy enough syntactically: you can change from {[k: SomeKeyType]: SomeValueType} to {[K in SomeKeyType]: SomeValueType}. This is the same as the Record<SomeKeyType, SomeValueType> utility type. And yes, if you want to leave out some keys, you could use Partial<>.

Some people like Partial<Record<K, T>> because it is a more "English"-like description of what you're doing. Still, if you find it horrible 😝, you can reduce your horror by writing the mapped type yourself:

let x: { [K in ResourceConstant]?: number } = {};

Now for the for..in loop stuff.

It would definitely be nice if you were allowed to annotate the iterating variable declaration inside for..in or for..of loops. Currently it can't be done at all (see microsoft/TypeScript#3500 for more info.)

But letting you annotate res as ResourceConstant would be a problem.

The big sticking point here is that object types in TypeScript are open, or extendible, and not closed, or exact. An object type like {a: string, b: number} means "this object has a string-valued property at key a and a number-values property at key b", but it does not mean "and there are no other properties". Extra properties are not prohibited. So a value like {a: "", b: 0, c: true} is assignable. (This is complicated by the fact that if you try to assign a fresh object literal with extra properties to a variable, the compiler does perform excess property checking But these checks are easy to circumvent; see the documentation link for more info).

Let's therefore imagine that we could write for (let res: ResourceConstant in x) without warning:

function acceptX(x: { [K in ResourceConstant]?: number }) {
  for (res: ResourceConstant in x) { // imagine this worked
    console.log((x[res] || 0).toFixed(2));
  }
}

Then nothing stops me from doing this:

const y = { A: 1, B: 2, D: "four" };
acceptX(y); // no compiler warning, but this explodes at runtime
// 1.00, 2.00, and then EXPLOSION!

Oops. I assumed that for (let res in x) would only iterate over some of A, B, and C. But at runtime a D got in there and messed everything up.

That's why they don't let you do that. In the absence of exact types, it's just not safe to iterate over whatever keys happen to be in an object of a "known" type. So, you could either be safe like this:

for (let res in x) {
  if (res === "A" || res === "B" || res === "C") {
    console.log((x[res] || 0).toFixed(2)); // no error now
  }
}

or this:

// THIS IS THE RECOMMENDED SOLUTION HERE
for (let res of ["A", "B", "C"] as const) {
  console.log((x[res] || 0).toFixed(2));
}

OR, you could be unsafe and use a type assertion or other workaround. The obvious assertion workaround is to create a new variable like this:

for (let res in x) {
  const res2 = res as ResourceConstant;
  console.log((x[res2] || 0).toFixed(2));
}

Or assert every time you mention res,

for (let res in x) {
  console.log((x[res as ResourceConstant] || 0).toFixed(2));
}

but if that's too horrible 😱 then you can use the following workaround (from this comment):

let res: ResourceConstant; // declare variable in outer scope
for (res in x) { // res in here uses same scope
  console.log((x[res] || 0).toFixed(2));
}

Here I've declared res in the outer scope as the type I wanted, and then just use it inside the for..in loop. This apparently works with no error. It's still unsafe, but maybe you find it more palatable than the other alternatives.


I think your frustration with TypeScript is understandable... but I hope I've conveyed that the walls you're hitting are there for reasons. In the case of mapped types and index signatures, the wall happens to connect two usable hallways but the architects don't yet know how to cut a door in it without having one side or the other collapse. In the case of annotating for..in loop indexers it's to stop people from falling into the open pit on the other side of that wall.

Playground link to code

Upvotes: 3

Related Questions