Bagel03
Bagel03

Reputation: 715

Define Class Method Names Based On Other Types Typescript

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

Answers (1)

jcalz
jcalz

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:

  • a property with a key of type `${Lowercase<N>}Set` whose value is of type Set<string>, and
  • a property with a key of type `add${Capitalize<N>}` whose value is of type (str: string) => void, and
  • a property with a key of type `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.

Playground link to code

Upvotes: 1

Related Questions