Reputation: 715
Say I have a bunch of classes that do similar things (for this instance add to a set ) but what they do is very different. Writing it out by hand results in code like this:
class Foo {
fooSet: Set<string> = new Set()
addFoo(foo: string) {
this.fooSet.add(foo)
}
delFoo(foo: string) {
this.fooSet.delete(foo)
}
// Foo code
}
class Bar{
barSet: Set<string> = new Set()
addBar(bar: string) {
this.barSet.add(bar)
}
delBar(bar: string) {
this.barSet.delete(bar)
}
// Bar code
}
Thats a lot of useless work. Instead, I want to make a function namedSet(name: string)
that returns a class with all those methods already in there. A first try of mine was:
export const NamedSet = <N extends string>(name: N) => {
const lowerName = name.toLowerCase() as Lowercase<N>;
const capitalName = name[0].toUpperCase() + name.substring(1) as Capitalize<N>;
return class {
private readonly [`${lowerName}Set`]: Set<string> = new Set();
[`add${capitalName}`](str: string): void {
this[`${lowerName}Set`].add(str);
}
[`del${capitalName}`](str: string): void {
this[`${lowerName}Set`].delete(str);
}
}
}
However this fails because A computed property name in a class property declaration must have a simple literal type or a 'unique symbol' type.
(the set initalizer) and Type '
${Lowercase}Set' cannot be used to index type 'this'.
(inside the methods) The second error I believe would be fixed if the class knew that it had ${Lowercase<N>}Set
. However, TS does not let me define it the normal JS way. Is this possible, and if so how?
Upvotes: 1
Views: 927
Reputation: 328658
The main problem you're running into is that interface and class declarations in TypeScript are required to have statically known key names; that is, their specific types need to be known to the compiler at compile time. Dynamic keys are rejected. (Note that index signatures are not what I mean by "dynamic keys" here. A string
index signature means that all values of the specific type string
are acceptable keys. The type string
is not generic.) So what can we do?
Well, we can definitely represent types with dynamic key types in the language; they just can't be interface or class declarations. The main way to have a dynamic key is to use a generic type alias of a mapped type where the key is generic. Something of the form type Foo<K extends string> = {[P in K]: XXX}
, for example. The canonical example of this is the Record<K, V>
utility type, which represents a type with keys of type K
and values of type V
.
By intersecting together object types of the form Record<K, V>
for some K
and V
, we can build up a representation of whatever dynamic type you want. In your case, let's describe the desired shape of an instance of the NamedSet<N>
class:
type NamedSet<N extends string> =
Record<`${Lowercase<N>}Set`, Set<string>> &
Record<`add${Capitalize<N>}`, (str: string) => void> &
Record<`del${Capitalize<N>}`, (str: string) => void>
So we have an object which has:
`${Lowercase<N>}Set`
whose value is of type Set<string>
, and`add${Capitalize<N>}`
whose value is of type (str: string) => void
, and`del${Capitalize<N>}`
whose value is of type (str: string) => void
.So if that's the type of instances of NamedSet<N>
, what's the type of the constructor? That would be a construct signature like this:
type NamedSetConstructor<N extends string> = new () => NamedSet<N>;
So a value of type NamedSetConstructor<N>
is a class constructor that takes no arguments when you call new
on it, and produces an instance of type NamedSet<N>
. That means we want your NamedSet()
function to return a value of type NamedSetConstructor<N>
.
Unfortunately, as said above, the compiler will never be able to verify that any particular class
declaration is assignable to NamedSetConstructor<N>
. Instead, we'll need to write some class
declaration that has the desired runtime behavior, give it a weak enough type so that the compiler accepts it, and then just assert that it's of type NamedSetConstructor<N>
. That means the implementation of NamedSet()
will not be particularly type safe; we need to be careful. But at least callers of NamedSet()
won't have a problem. Here goes:
const NamedSet = <N extends string>(name: N) => {
const lowerName = name.toLowerCase()
const capitalName = name[0].toUpperCase() + name.substring(1)
return class {
[k: string]: any;
constructor() {
this[`${lowerName}Set`] = new Set<string>() as any;
}
[`add${capitalName}`](str: string): void {
this[`${lowerName}Set`].add(str);
}
[`del${capitalName}`](str: string): void {
this[`${lowerName}Set`].delete(str);
}
} as NamedSetConstructor<N>
}
Note that the class expression has a string index signature of value type any
, which is very loosely typed. And even so I need to assign this[`${lowerName}Set`]
with another assertion to avoid errors. And at the end we assert the whole thing as NamedSetConstructor<N>
.
So, does it work?
class Foo extends NamedSet("foo") { }
const foo = new Foo();
foo.addFoo("abc");
foo.addFoo("def");
foo.delFoo("abc");
console.log(Array.from(foo.fooSet)) // ["def"]
class Bar extends NamedSet("bar") { }
const bar = new Bar();
bar.addBar("ghi");
bar.addBar("jkl");
bar.delBar("jkl");
console.log(Array.from(bar.barSet)) // ["ghi"]
Looks good. The compiler recognizes that foo
has methods addFoo
, delFoo
, and a fooSet
property, and that bar
has methods addBar
, delBar
, and a barSet
property, and they all behave as desired at runtime.
Upvotes: 1