Reputation: 2216
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
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.
Upvotes: 1