TLDR;
Here’s the code:
type Handler = ((...args: any[]) => any) | string;
function safeSetTimeout<F extends Handler>(
handler: F,
timeout?: number,
...args: F extends string ? any[] : Parameters<F extends string ? never : F>
) {
return setTimeout(handler, timeout, ...args);
}
If you understand everything in the following snippet, you don’t have much to gain from this post. But you might want check out the practical snippet at the end of this post.
Otherwise, stick around and let’s produce some stricter variants of your loved setTimeout
and setInterval
.
The problem
If you check the type definitions for timers in Typescript, you will find this:
type TimerHandler = string | Function;
setInterval(handler: TimerHandler, timeout?: number, ...arguments: any[]): number;
setTimeout(handler: TimerHandler, timeout?: number, ...arguments: any[]): number;
The first thing this reminds us of is that these timers can take 3+ arguments, most of us are used to passing only two. The delay/interval and some callback.
The TimerHandler
definition also says that it can be a string, which gets eval()
ed and executed.
I’m pretty sure you had the lecture of evil eval elsewhere, so I will not bore you with it here. But it is still interesting from a type point of vue as the type system has no way of deducing what an arbitrary string might do. So nothing to do in this regard.
The third argument and beyond get passed to the handler
upon invocation. But their types and and the types that the handler
expects are completely unrelated, those any
and Function
are so loosy goosy.
You could pass cabbage to a function that expects a number
and typescript will still be happy.
Let’s change that!
The solution
We want a way to link the type of a callback function’s arguments to whatever other arguments gets passed to the caller.
For example, this apply
higher order function takes in a callback
from string
to number
and string as arguments
and gives us back the result of the application of that callback
, which Typescript accurately infers as a number
.
const apply = (callback: (x: string) => number, arg: string) => callback(args);
But what if we want to make the callback
’s input arbitrary, after all, all that apply
cares about is that arg
matches
the input of callback
Enter generics. We can tell Typescript, hey, see this T
? I will give you a callback
that consumes it and a corresponding arg
.
const applyGeneric = <T>(callback: (x: T) => number, arg: T) => callback(arg);
And when we use it like this, we get a compilation error:
const exclaim = (x: string, times = 1) => x + "!".repeat(times);
// Argument of type '(x: string) => string' is not assignable to parameter of type '(x: string) => number'.
// Type 'string' is not assignable to type 'number'.
applyGeneric(exclaim, 0);
Typescript is not happy as the 0
“constrains” T
to be a number
and exclaim
consumes T
s of type string
.
What about a generic return type of callback
? easy enough. Just add another generic parameter.
const applyGeneric = <T, R>(callback: (x: T) => R, arg: T) => callback(arg);
// Argument of type 'number' is not assignable to parameter of type 'string'.
applyGeneric(exclaim, 0);
And as nice side effect, notice the more specific compile error message from the previous example.
So far so good, but what if we have more than one argument to pass to callback
?
We could just other generic parameters to apply
and overloads. But it gets ugly fast.
Luckily, Typescript enables us to have the type of a functions arguments using the Parameters
utility type,
which is generic over a function type and gives us the type of its parameters as tuple type.
A function’s type is essentially its signature. In this example, Params1
and Params2
are equivalent to the tuple type Params3
.
const exclaim = (x: string, times = 1) => x + "!".repeat(times);
type Params1 = Parameters<(x: string, times?: number) => string>;
type Params2 = Parameters<typeof exclaim>;
type Params3 = [x: string, times?: number];
And the return type ? we have ReturnType<F>
for that in a similar manner.
With this mind, let’s get back to applyGeneric
:
const applyGeneric = <F extends (...args: any[]) => any>(
callback: F,
...args: Parameters<F>
): ReturnType<F> => {
return callback(...args);
};
We have the extends
keyword here, it’s used to place a “constraint” on F
so that it only accepts functions.
And F
is used to tell the compiler that the type of callback
is the same as the thing we passed to Parameters
.
This function is so versatile you can throw any callback
to it with any number of arguments and it will just work.
In essence, setTimeout
and setInterval
are higher order functions similar to our applyGeneric
, but we don’t have to worry about
the return type as it is already known. So a simple implementation would look like this:
const safeSetTimeout = <F extends (...args: any[]) => any>(
callback: F,
timeout?: number,
...args: Parameters<F>
) => {
return setTimeout(callback, timeout, ...args);
};
const safeSetInterval = <F extends (...args: any[]) => any>(
callback: F,
timeout?: number,
...args: Parameters<F>
) => {
return setInterval(callback, timeout, ...args);
};
This will work for all intents and purposes, and it will force you to not pass in a string
for the callback
.
But if you really want to make the signatures identical, then any
will creep in when you use a string
for callback
.
So coming back full circle to the snippet at the beginning of the post, the only difference from this implementation is
the use of type conditionals to revert back to the original behavior when callback
is a string
Does it all make sense now ? Do you find yourself using arguments
beyond timeout
to timers ?