15

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.

3 Answers 3

29

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. Index signatures require a dummy key name identifier (k in the example here), which is not a type parameter. 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 object 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

Sign up to request clarification or add additional context in comments.

2 Comments

Thank you so much for this complete answer. I guess the links you shared will help me understanding much better TS. Reading what's said I guess I didn't really differentiate mapped types & index signatures, I'm going to deal with these concepts in depth. And thanks for the solutions, the first is the one I used in my project. I think I'll use the second with the Record utility type, more flexible than 3rd (some custom keys may have different types).
This is the ultimate guide for how to provide a SO answer. The explanations of the WHY and the links from github have eased a bit of the frustration. Although I hope TS Team will realize the benefit of this new syntax. If intersection works then most probably this new syntax can be coded as an interpreter trick. Thanks again!
2

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.

Comments

1

Suppose you have to build this object type:

type TCategoryType = {
  customKey1: string
  customKey2: string
  [key in Category]: string
}

it will give you the error you mentioned above.

But you can declare it using & so now it looks like this:

type TCategoryType = {
  [key in Category]: string
} & {
  customKey1: string
  customKey2: string
}

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.