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 number
s.
Playground link to code