Use Typescript generics for a type safe `setTimeout` and `setInterval`

Use Typescript generics for a type safe `setTimeout` and `setInterval`

ยท

5 min read

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;

typescript any.png

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 Ts 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 ?

Please let me know what you think, ask questions and suggest future topics I should cover in future posts in the comments below.

Thank you for reading, I hope you found this post helpful, don't forget to follow for more ๐Ÿค—.

ย