Home > Blockchain >  Typescript: how to type an object with type values and other keys
Typescript: how to type an object with type values and other keys

Time:10-20

I'm facing an issue when typing an object with TypeScript

I declared a Type which is used by some files of the app:

type Category = "cat1"|"cat2"|"cat3"|"cat4"

I use Category to type an object & everything is working fine using:

const obj: {
  [key in Category]: string
} = {---}

But now, I'd like to add 2 keys that are not Category values on the object. I thought it would be easy by typing the object like this:

const obj: {
  customKey1: string
  customKey2: string
  [key in Category]: string
} = {---}

Buut instead of working as expected, TS send 3 errors: TS2464, TS1170 & TS2693

A computed property name must be of type 'string', 'number', 'symbol' or 'any'. ts(2464)
'Category' only refers to a type, but is using as a value here. ts(2693)
A computed property name in a type template must refer to an expression whose type is a literal type or a 'unique symbol' type. ts(1170)

Ok... Why ? As Category is shared over files I don't want to edit it by adding these custom keys which are only needed here. I solved this problem, so if somebody else is facing this issue I give a solution below, but does anyone know why my first idea can't be applied ?
I don't understand why writing [key in Category]: string works if alone but throws errors if another key is added.

CodePudding user response:

For the ones who don't need explanations but only an available solution, here's mine:

type Keys = { [key in Category]: string }
interface Obj extends Keys {
  customKey1: string
  customKey2: string
}
const obj: Obj = {---}

It's working fine.

CodePudding user response:

The {[K in XXX]: YYY} syntax is a mapped type, a special object type which iterates over the union members of keylike type expression XXX and uses a type parameter K for each one inside the YYY type expression. (Note, K represents a type, not a property key value, so by convention we use uppercase characters there). You can use K inside the YYY expression so that each key K in XXX can have a different value type, like {[K in "a" | "b"]: K} is equivalent to {a: "a", b: "b"}. Mapped type syntax is not generalizable or extendable; you can't put other properties in there like {[K in XXX]: YYY; somethingElse: string}, and you can't put them inside interface declarations, or do anything else with it. It is a syntax error to do so. In some sense, the curly braces { ... } are part of the syntax for mapped types, and even though they look like the curly braces in other object types, they don't act like them.

It's important not to confuse this syntax with the similar-looking {[k: XXX]: YYY} syntax for index signatures. Mapped types use the in keyword, while index signatures do not, and require a dummy key name identifier (k in the example here). The dummy key name exists only inside the key and cannot be used in the YYY expression, so it does not allow you to assign different values for different keys in the XXX; index signatures do not iterate over the keys inside XXX in any way. As signatures, index signatures can be used in any object type alongside other properties, like {[k: XXX]: YYY; somethingElse: string}, as long as the other properties don't conflict with the index signature. They can be included in interface declarations. The curly braces are not part of the syntax for index signatures.


Your question is therefore: why can't you add other properties to a mapped type? why do the curly braces in mapped types not work the way they do in other mapped types?

There was an issue at microsoft/TypeScript#13573 asking this exact question. There doesn't seem to be a definitive answer, though. It seems like an unanticipated use case. For a while, it was possible that a pull request at microsoft/TypeScript#26797 would be merged into the language as part of an effort to unify mapped types and index signatures, but this never happened. A relevant comment in microsoft/TypeScript#45089 by a TS team member explains that at this point it's unlikely that anything will change here, because it opens up questions about how to deal with possibly conflicting types and generics:

There are weird implications of mixing mapped types and property declarations that are elegantly solved with intersections

I won't go into these explicitly here; you can look at the linked issue for more information.


So that is the answer to "why is it like this", as far as it goes. So what can you do instead? The above comment mentions intersections; you can merge the properties of two object types together via intersections, so {a: string} & {b: string} is essentially the same as {a: string; b: string}. So one way to write your object type is this:

type MyObjType =
  { [K in Category]: string } &
  { customKey1: string, customKey2: string };

You can't use intersections as interface types directly, but you are allowed to make interface extend other named types with statically known keys. Your {[K in Category]: string} has statically known keys because Category is not generic, but it's not a named type. You could either name it yourself and then extend it:

type CategoryProps = { [K in Category]: string };
interface MyObjType extends CategoryProps {
  customKey1: string
  customKey2: string
}

Or, you could use the Record<K, V> utility type and extend that without having to declare a new name:

interface MyObjType extends Record<Category, string> {
  customKey1: string
  customKey2: string
}

Finally, you could always go the other direction and use a mapped type for all your keys instead of trying to add them separately:

type MyObjType = 
  { [K in Category | "customKey1" | "customKey2"]: string };

Playground link to code

  • Related