Dynamic Typing in TypeScript using Generics

Dynamic Typing in TypeScript using Generics

Travis Waith-Mair's photo
Travis Waith-Mair
·Jul 31, 2021·

5 min read

Subscribe to my newsletter and never miss my upcoming articles

Photo by Obi Onyeador on Unsplash

In my previous post, we created a generic function wrapper to set a timeout on the function. The function works amazingly well, but it is written in vanilla JavaScript. If you desire the extra confidence you get when writing in TypeScript, we need to take it a step further.

As a reminder, this was the final version:

function wait(milliseconds) {
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
}

export function withTimeout(promiseFn, opts) {
  const timeOut = opts?.timeOut ?? 2500;

  const errorMessage =
    opts?.errorMessage ?? `Your Request timed out after ${timeOut}ms`;

  return (...args) =>
    Promise.race([
      promiseFn(...args),
      wait(timeOut).then(() => {
        throw new Error(errorMessage);
      }),
    ]);
}

In most cases, adding types is very easy. The wait function helper is simple to add a type. milliseconds is of type number and we can add that inline, like this:

function wait(milliseconds: number) {
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
}

The opts argument in the withTimout function can also be typed easily. It has two optional properties: timeOut which is type number and errorMessage which is type string. We could type that in line, but it would be much cleaner to read if we declared the type separately, like this:

type Options = { timeOut?: number; errorMessage?: string };

Then we can use our new Optionstype inline in our function, like this:

function withTimeout(promiseFn, opts?: Options) {
  //
}

Unfortunately our promiseFn argument is not so easy to type, and neither is the return type of our withTimeout function. This is because we are accepting any function as long as it returns a Promise. So how do we add types when we don’t know what type something is in the first place? That’s where Generics come in.

TypeScript Generics

For a good primer on TypeScript Generics, I would recommend Ross Bulat’s post on TypeScript Generics. To summarize, Generics allows us to put in placeholders for types, which lets those types be defined later on.

Let’s start with what we do know about the type of the promiseFn and the return type. We know they are both functions that return a Promise, so let’s add that in now:

function withTimeout(promiseFn: () => Promise, opts?: Options): () => Promise {
  //
}

Both of our Promises need a type, which we don’t know in advance. This is a great time to add a Generic:

function withTimeout<T>(
  promiseFn: () => Promise<T>,
  opts?: Options
): () => Promise<T> {
  //
}

In the above code, we set a placeholder T to stand in for whatever the type might be. And then, we are using it in our type definitions for our PromiseFn and return type. Then we can use it like this:

const getUserWithTimeout = withTimeout<UserType>(getUser);

In the above code, we are setting the function's return type to be Promise<UserType> simply by defining the type between the two angle brackets when we call the function.

In a way, we can think of generics like arguments to a function that can be passed in. Just like arguments in a function, we can also set default types, like this:

function withTimeout<T = void>(
  promiseFn: () => Promise<T>,
  opts?: Options
): () => Promise<T> {
  //
}

Now the return type will default to void if none is provided.

This is great, but we are still missing the types for the arguments of the function. We can define more than one generic like this:

function withTimeout<K, T = void>(

We don’t know how many values, if any, that the function will take. So how do we type it? Remember how we used the rest operator, ... to bundle all the arguments into an array called args? That array is an array of unknown values, which can be typed as Array<unknown>. With that background, we can type our arguments like this:

function withTimeout<K extends Array<unknown>, T = void>(
  promiseFn: (...args: K) => Promise<T>,
  opts?: Options
): (...args: K) => Promise<T> {
  //
}

In the above code, we are saying that K can be any type as long as the type can extend Array<unknown>. Since an array of any type can extend an Array<unknown>, this will enforce that K must be an array. Then we define args as being of type K in both the promiseFn type and the return type.

(I would like to point out that you could refactor the promiseFn and the return type into a single type. However, in this case, I feel that would be too much indirection, and the types are very legible as in their current state)

Now we can call our function like this:

const getUserWithTimeout = withTimeout<\[UserId\],UserType>(getUser)

This means that withTimeout will take and return function with a single argument that is of type userId and will return a Promise<UserType>.

Now everything is working correctly, but there is one more awesome thing left. TypeScript is pretty good at inferring types. This means that we actually don’t have to pass any types in at all, and Typescript will be able to infer the types directly

Let’s recall the above function, this time without declaring the types first:

const getUserWithTimeout = withTimeout(getUser);

Our code is still simple to use like Vanilla JS, but has all the type safety that TypesScript gives us.

The Final Version

Here is the final version of the the withTimeout function with all the types:

Generics gives our Typescript a superpower. Allowing us to maintain a level of dynamism while still gives us that confidence of type safety. If you haven’t already, I would recommend you try it out and see what problems it solves for you.

Did you find this article valuable?

Support Travis Waith-Mair by becoming a sponsor. Any amount is appreciated!

See recent sponsors Learn more about Hashnode Sponsors
 
Share this