2

I declare a class like this:

declare class MyClass {
  key: string

  data: {
    [this.key]: string; // error: A computed property name in a type literal must refer to an expression whose type is a literal type or a 'unique symbol' type.
    otherProps: { [k: string]: number | undefined; }
  }
}

The intended behavior is that the data object should be typed so that if the key equals the one passed into the constructor, then it maps to a string. This is because I want to achieve the following:

class MyClass {
  myFunction () {
    const value1 = this.data[this.key] // value1 should be type string
    const value2 = this.data.otherProps.foo // value2 should be type number | undefined
  }
}

However this doesn't work and throws an error as seen in the comment in the first snippet. How can I make this work?

jcalz
  • 264,269
  • 27
  • 359
  • 360
Susccy
  • 214
  • 2
  • 13
  • You've got at least one typo in there (you can't implement a constructor in a `declare class`), could you make sure this demonstrates your issue and only your issue when viewed in an IDE? – jcalz Jun 06 '22 at 18:01
  • There's two separate problems going on here and for best results you should edit your question to focus on one of them. The first is that you need the `data` property to have a strongly typed property at key `this.key`. The second is that you want the `data` property to have any number of `string` keys of type `number | undefined`, *except* for a single key that does not conform to that. This second bit is not directly possible and I'd point you to [this question](//stackoverflow.com/q/61431397/2887218) to look at the options. So, which one of those problems is the focus of your question? – jcalz Jun 06 '22 at 18:04
  • 1
    By the way, [this approach](https://tsplay.dev/wgZz9W) is how I'd deal with your first problem (note how I sidestepped the second problem by moving the other conflicting props down into an `otherProps` property). If that meets your needs I can write up an answer. – jcalz Jun 06 '22 at 18:10
  • @jcalz oops I didn't notice the constructor isn't allowed here, but I only added it to show that I have an instance variable `private key: string`. The constructor is not in my actual `declare class`. I also didn't realize that I asked 2 separate problems in my question, but the focus is on the first one you described. Your approach on TS Playground worked for me and you can add it as an answer! :) – Susccy Jun 07 '22 at 08:19
  • @jcalz I only have 1 question about your TS Playground solution: in line 3, what is the `key: Exclude` for when `K` is just a string - isn't `key: K` sufficient? – Susccy Jun 07 '22 at 08:24
  • 1
    If someone calls `new MyClass("otherProps")` then you would like an error, since then the `otherProps` prop is now supposed to be both a string and a dictionary, and it can't be both of those. You could leave that off if you're not concerned about it. – jcalz Jun 07 '22 at 14:50
  • So, I will write up an answer; do you mind if I [edit] your question to use `otherProps` instead of `[k: string]` so that I don't have to get into the lack of "index-signature-with-exceptions" support in TS? – jcalz Jun 07 '22 at 22:57
  • @jcalz yes please feel free to edit it to narrow down the focused problem. One more question regarding that though, if we would set `[k: string]: unknown` then it would work without `otherProps` right? – Susccy Jun 08 '22 at 08:31
  • Yes, like [this](https://tsplay.dev/NrnvzN). Using `unknown` sidesteps the problem of having an exception to the index signatures, since everything, including `string`, is assignable to `unknown`. Would you prefer I edit/answer the question that way instead of with `otherProps`? – jcalz Jun 08 '22 at 14:11
  • @jcalz no please keep the `number | undefined` type – Susccy Jun 10 '22 at 07:58

1 Answers1

1

In order for the compiler to keep track of the literal type of the key passed in, we need MyClass to be generic in that type. So instead of MyClass, you'll have MyClass<K extends string>.

class MyClass<K extends string> {

Then, you want to say that data has a key of type K whose property value at that key is string. This is potentially confusing, because the syntax for a computed property name (e.g., {[keyExpression]: ValType}), the syntax for an index signature (e.g., {[dummyKeyName: KeyType]: ValType}), and the syntax for a mapped type (e.g., {[P in KeyType]: ValType }) look so similar, yet have different rules for what you can and can't do.

You can't write { [this.key]: string } because that would be a computed property key, which, as the error says, needs to have a statically known literal/symbol type. Instead, since the key type K is generic, you need to use a mapped type. So you could write {[P in K]: string}, or the equivalent Record<K, string> using the Record<K, V> utility type.

Oh, and if data should also have other properties, you will need to intersect Record<K, string> with the rest of the object type, because mapped types don't allow you to add other entries.

  data: Record<K, string> & { otherProps: { [k: string]: number | undefined } 

And then you need the constructor. You could have it take a key of type K, but there's the chance that someone could pass in "otherProps", which would collide with the existing otherProps property. To prevent that, we can write Exclude<K, "otherProps"> using the Exclude<T, U> utility type. If the type of key does not include "otherProps", then Exclude<K, "otherProps"> is just K. If it does, then Exclude<K, "otherProps"> won't be assignable to K and the compiler will be unhappy about calling that constructor:

  constructor(key: Exclude<K, "otherProps">) {
    this.data = { [key]: "prop", otherProps: { a: 1, b: 2, c: 3 } } as typeof this.data;
  }
}

new MyClass("okay"); // okay
new MyClass("otherProps"); // error!

And now let's test it out:

const c = new MyClass("hello"); // okay
console.log(c.data.hello.toUpperCase()); // PROP
console.log(Object.entries(c.data.otherProps).map(
  ([k, v]) => k + ":" + v?.toFixed(2)).join("; ")) // "a:1.00; b:2.00; c:3.00" 

Looks good. The compiler is aware that c.data has a string-valued hello property, while still accepting the otherProps property as a dictionary of numbers.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360