Guid
Guid

Reputation: 2216

Do not repeat myself

I've got a typescript class:

class C {
  #fsm
  (...)
  startFoo(name: string) {
    this.#fsm.send('FOO', name)
    return this
  }
  startBar(name: string) {
    this.#fsm.send('BAR', name)
    return this
  }
  (...)
}

startFoo and startBar are just two examples but I have tens of similar functions and I was wondering how I could avoid to repeat myself. Would it be possible with decorators?

Upvotes: 1

Views: 58

Answers (1)

jcalz
jcalz

Reputation: 327984

Here's one possible approach. First, we can add a protected method to which we can delegate all the calls to the startFoo(), startBar(), et cetera. Let's call this method startify(), and it takes an extra argument called type:

class C {
    #fsm = {
        send(type: string, name: string) {
            console.log("I was sent type \"" + type + "\" and name \"" + name + "\"");
        }
    }
    protected startify(type: string, name: string) {
        this.#fsm.send(type, name);
        return this;
    }
}

Now we want to add the startXxx methods to the class prototype, as well as letting the compiler know that class instances have these methods. Let's walk through it:

const methodNames = ["foo", "bar", "baz"] as const;

The methodNames array contains all the (lowercase) names of the part after start, and I am assuming that these names, when converted toUpperCase(), is the value you want to pass to startify(). I use a const assertion so that the compiler keeps track of the literal types of the strings inside it. You can make this as array long as you need (I added "baz" to demonstrate).

type MethodNames = typeof methodNames[number];

The MethodNames type is a helper and resolves to the union of the string literal types in methodNames. In this case it is "foo" | "bar" | "baz".

type CMethods = { [K in MethodNames as `start${Capitalize<K>}`]: { (name: string): C } };

The CMethods is an object type whose keys are MethodNames remapped by init-capping the first letter with the Capitalize<T> intrinsic utility type and prepending "start" to it. So the keys here are "startFoo", "startBar", and "startBaz". The property values at these keys are methods that accept a string argument and produce a value of type C. (The "right" return type would be the polymorphic this type, but this can't be represented externally to the class, so I'm not going to worry about it. If you ever subclass C, the inherited methods will only be known to return C and not whatever the subclass is. 🤷‍♂️). So CMethods is the part of the C instance type we'd like to add. And here's how we add it:

interface C extends CMethods { }

This is known as declaration merging. With this line, the compiler now knows that every instance of C has all the methods from CMethods as well as anything declared inside class C {}.

Now the compiler knows about the methods, but we need to actually add them. First let's make a helper function capitalize() that does at a value-level what Capitalize<> does at the type level:

const capitalize = <K extends string>(k: K) => k[0].toUpperCase() + k.slice(1) as Capitalize<K>;

And here we go... for each element of methodNames, we add to a method with the appropriate name to C.prototype, and this method is delegated to startify via the call() method.

methodNames.forEach(k => C.prototype[`start${capitalize(k)}`] = function (this: C, name: string) {
    return C.prototype.startify.call(this, k.toUpperCase(), name);
});

Let's see if it works:

const c = new C();
c.startBar("barf").startFoo("food")
// I was sent type "BAR" and name "barf"
// I was sent type "FOO" and name "food"

Looks good! The compiler expects c.startBar() to exist, and return a C so the calls can be chained. And at runtime the call is actually delegated properly.

Playground link to code

Upvotes: 1

Related Questions