I have searched everywhere but didn't get a clue on how I can achieve such a behavior. I'm new to typescript, thus forgive me if it's a dumb question.
The code below is a very simplified version of what I'm looking for:
const library = { // could also be a class if necessary
registerMethod(name: string, method: () => any): void {
this[name] = method;
}
}
library.registerMethod('couldBeWhatever', () => { }); // could be called any number of times with custom functions and names
I'm looking for a way (not necessarily the one above) to register methods of a class or object at startup with allowing completion on those dynamic functions anyway. Is that possible in typescript ?
I'm guessing that by "at runtime" you mean that you want the compiler to perform control flow anlysis to try to predict what methods will be available by examining TypeScript code that calls libray.registerMethod()
. Obviously there is no way to get completion in your IDE for anything that will only be known about at runtime, such as if your code generated a random method name string with Math.random()
or pulled the method name from user input or API response or something.
If so, then you might want to use assertion functions. An assertion function narrows the type of one of its arguments (you can also have an assertion method that narrows the type of the object on which it is called).
By narrowing, it means that the apparent type after the assertion function must be a subtype of the apparent type before the assertion function. For example, you can't make an assertion function turn a string
into a number
, but you can make it turn a string | number
into a number
. So one of the caveats of using control flow analysis to model mutation of library
is that it cannot be used to make it incompatible with the original type. Your registerMethod()
method looks like it will add new members to library
, and adding members to an object type is a form of narrowing. But if you needed an alterMethod()
that say, changes the type of the registered method from () => string
to () => number
, you wouldn't be able to do this.
Here's an example implementation of library
with registerMethod()
as an assertion method:
interface _Lib<T extends Record<keyof T, () => any>> {
registerMethod<K extends string, M extends () => any>(
name: K,
method: K extends keyof T ? T[K] : M
): asserts this is Library<{
[P in K | keyof T]: P extends K ? M : P extends keyof T ? T[P] : never
}>
}
type Library<T extends Record<keyof T, () => any>> = T & _Lib<T>;
const library: Library<{}> = {
registerMethod(this: Library<any>, name: string, method: () => any): void {
this[name] = method;
}
}
There's a lot going on in there, but the basic idea is that library
starts off as a Library<{}>
, with only a registerMethod()
method. When you call registerMethod()
on a Library<T>
with a name of type K
and a method of type M
, the compiler will narrow that Library<T>
to something like a Library<T & Record<K, M>>
. Meaning that, in addition to having registerMethod()
and whatever was in T
, it now also has a member at key K
whose type is M
.
You can test that it works:
library.registerMethod('couldBeWhatever', () => "hello");
console.log(library.couldBeWhatever().toUpperCase()); // HELLO
library.somethingElse; // error, somethingElse does not exist
library.registerMethod('somethingElse', () => 123);
console.log(library.couldBeWhatever().toUpperCase()); // still HELLO
console.log(library.somethingElse().toFixed(2)); // "123.00"
Hooray, it works! There are caveats that go along with assertion functions, though, which may or may not matter for your use case.
First of all, in order to use an assertion function you need it or the object you call it on to be manually annotated and not inferred. That's why I had to literally annotate library
above as being Library<{}>
. This is currently a design limitation of TypeScript. See microsoft/TypeScript#36931 among others, for more information.
Next, as with all control flow analysis narrowing in TypeScript, the compiler isn't all-knowing. It does not perform control flow analysis by simulating all possible ways to run the program and seeing which possible type narrowings stay true in all scopes. It would be prohibitively expensive to do so. Right now, what the compiler does is: when you cross a function boundary, the compiler resets any control flow narrowings. This is a reasonable trade-off, since the compiler cannot generally figure out when a function body will be called with respect to code outside the function body, or vice versa. See microsoft/TypeScript#9998 for a discussion about this issue.
What it means for the above code: when you register methods with library
, you will be able to use them afterward in the same function scope. But you cannot use them "afterward" in some arbitrary other scope like a function body or some other module:
library.registerMethod('somethingElse', () => 123);
function oops() {
library.somethingElse() // the compiler doesn't know about this
}
The compiler really doesn't know that by the time oops()
is called, somethingElse
will have been registered on library
. Actually I don't know it either without examining all of the code everywhere in the program.
A workaround for this would be to do all your registering somewhere, and then "freeze" the type of the resulting library by "saving" it into a new const
variable.
const registeredLibrary = library;
function okay() {
registeredLibrary.somethingElse(); // the compiler does know about this
}
That works because registeredLibrary
was created as a copy of library
in a scope where methods had been registered on it already. There's no place in the code where registeredLibrary
fails to have those two extra methods, so the compiler is happy to use them inside the okay()
function body.
It is quite possible that the above caveats make this not work for you. The compiler is quite powerful, but unable to handle analysis that needs to happen all at once everywhere for all possible ways the code can run. But assertion methods can at least go some of the way toward modeling this kind of "dynamic" behavior.