farmdev

Safer TypeScript With Generics And Linting

TypeScript makes JavaScript safer and even opens up new architectural possibilities such as better unit tests. I highly recommend it. However, the default configuration encourages unsafe patterns that developers might not be aware of.

Specifically, I wanted to illustrate the dangers of any and show how you can define custom generics instead. I'll also offer some lint rules that will help your team write safer TypeScript.

Tightening up the config

For starters, a config with at least these options will buy you some safety:

{
  "compilerOptions": {
    "noImplicitAny": true,
    "strict": true
  }
}

Let's look at some examples using this config.

Working with an API

Imagine you're working with a RESTful API in TypeScript and you want to create a helper function that calls fetch(). It will take a string endpoint and return a Promise for the JSON result:

async function request(endpoint: string) {
  const response = await fetch(`https://api.com/v1${endpoint}`);
  return response.json();
}

Here's a function to fetch a user and return their name:

async function getUserName(userId: number) {
  const user = await request(`/users/${userId}`);
  return user.name;
}

This compiles without any errors. Wait, what?! You didn't define a type for user so how does the compiler know it has a name attribute? What happens if the API renames it to user.firstName and you want to be sure you've updated all your code?

TypeScript aims to preserve runtime behavior of all JavaScript code which, I guess, is why many things are unsafe by default. Let's add more type safety.

Enforcing return types

The two functions above don't define return types which means TypeScript inferred them. In the case of calling response.json(), the return value was typed as Promise<any> which is where all type safety went out the window. TypeScript lets you call user.name because user is of type any so, whatever, man.

You can begin protecting against this by configuring a linter with the explicit-function-return-type rule. This will make the above code fail linting with something like:

error  Missing return type on function

Let's fix it:

type User = { name: string };

async function request(endpoint: string): Promise<User> {
  const response = await fetch(`https://api.com/v1${endpoint}`);
  return response.json();
}

async function getUserName(userId: number): Promise<string> {
  const user = await request(`/users/${userId}`);
  return user.name;
}

This forced us to define a User type so that the compiler knows user.name is a valid property.

Type inferrence is a nice feature but I came to regret not enforcing explicit function types in a recent application I worked on. It was convenient but I can think of several production bugs that would have been caught by it. It's worth the extra effort.

I see diamonds! <>

Yep, I snuck some generics into the previous example. The type definition Promise<string> uses the generic type, Promise, to say getUserName() returns a Promise resolving to a string type. I'll show examples of defining custom generics in a minute.

The case for generics

Let's say you need another function to call a different endpoint, this time to fetch user roles:

type UserRole = { type: "admin" | "staff" };

async function getUserRoles(userId: number): Promise<UserRole[]> {
  return request(`/users/${userId}/roles`);
}

This won't compile because request() was defined as always returning a User object which was only true for the other endpoint. What can we do?

We could say that request() returns Promise<any>, right? Let's try it:

async function request(endpoint: string): Promise<any> {
  const response = await fetch(`https://api.com/v1${endpoint}`);
  return response.json();
}

Yep, that compiles. Huh. Now we're back to the first problem where any lets us do anything without type safety. The most dangerous part is that any crept into the code and made everything unsafe but there was no error. A code reviewer might even miss it.

Enhancing safety by disallowing any

any is a dangerous drug so let's quit cold turkey by adding the no-explicit-any lint rule. This wouldn't catch how response.json() is defined internally by TypeScript to return Promise<any> but at least it will catch any within our own code, showing something like:

error  Unexpected any. Specify a different type

Defining a custom generic

To ditch any and safely call request() on endpoints of differing types, we need to give it a type variable. We'll call it Data:

async function request<Data>(endpoint: string): Promise<Data> {
  const response = await fetch(`https://api.com/v1${endpoint}`);
  return response.json();
}

The <Data> part lets us say the /users/:id endpoint (but not others) returns a Promise resolving to a User:

type User = { name: string };

async function getUserName(userId: number): Promise<string> {
  const user = await request<User>(`/users/${userId}`);
  return user.name;
}

This now makes it safe to access user.name and would allow us to easily change it to user.firstName or anything else in the future if we need to.

Let's rewrite the other one:

type UserRole = { type: "admin" | "staff" };

async function getUserRoles(userId: number): Promise<UserRole[]> {
  return request<UserRole[]>(`/users/${userId}/roles`);
}

This says request() will return a Promise resolving to a UserRole array and adds all of the same safety benefits.

A more complex generic

So far our request() helper deals with GET requests but what if we add support for POST, which introduces a request body? We'll need an additional type variable, something like request<ResponseType, BodyType>(...) or, more typically, request<R, B>(...) (you'll see a lot of single letter type variables in the wild).

Instead, I prefer to define the type variable as an object with meaningful keys since it makes the calling code easier to read. Here's a new definition of request() that supports both GET and POST requests:

async function request<
  D extends { BodyType: undefined | {}; ResponseType: {} }
>(
  method: "GET" | "POST",
  endpoint: string,
  body?: D["BodyType"]
): Promise<D["ResponseType"]> {
  const response = await fetch(`https://api.com/v1${endpoint}`, {
    method,
    body: body ? JSON.stringify(body) : undefined
  });
  return response.json();
}

This required a bit more code and we now have a type constraint (via the extends keyword) to denote the object shape. Specifically, we're saying request() takes a type variable D which is an object having the keys BodyType and ResponseType.

Let's define a function to make a GET request:

type User = { name: string };

async function getUser(userId: number): Promise<User> {
  return request<{ BodyType: undefined; ResponseType: User }>(
    "GET",
    `/users/${userId}`
  );
}

This defines D as an object where BodyType is undefined (GET requests typically don't have them) and ResponseType is User.

Here's a function that makes a POST request:

async function createUser(user: User): Promise<User> {
  return request<{ BodyType: User; ResponseType: User }>(
    "POST",
    "/users",
    user
  );
}

This defines D where both BodyType and ResponseType are a User.

Bolt-on solutions

I've shown how out of the box TypeScript isn't very safe and presented some techniques for making it safer. I also suggest hunting for third party libraries that seek to specifically address type safety, as there are quite a few.

One in particular, io-ts helps make fetch() and other common I/O patterns type safe. When working in Redux, typesafe-actions adds type safety to actions as well as reducers, for example.

The case for any types

Hopefully by now I've convinced you not to build a TypeScript app with any types but there are still valid cases for them. If you're converting a legacy code base to TypeScript, falling back on any is a powerful way to maintain forward momentum. Converting a large code base could take longer than you think. It's a good investment but it still takes time away from shipping product features so one often has to do it incrementally.

There are other powerful escape hatches in TypeScript like @ts-ignore and @ts-nocheck. I'm a big fan of spike, test, break, fix development. During the spike phase, it's super helpful to let the TypeScript compiler guide you. However, you may want to ignore type errors in things like test files while experimenting with architectural changes. Using @ts-nocheck in test files (just temporarily) is really handy.

Ship with confidence

The TypeScript learning curve steepens when approaching generics but hopefully this gives someone a boost. I showed how to add type safety to a flexible function and call it in different ways. Check out TypeScript's docs on generics for more details.

I also provided some lint rules to help keep a codebase safe as it evolves. All of this has to be enforced with continuous integration for it to add lasting value.

If you'd like to play around with these code examples, I've made them available. However, they are just examples. Rather than building a type safe API client from scratch, check out axios. It supports TypeScript and is designed for type safety.

To go further, here is a deep dive into TypeScript that was written to address many common problems people run into when getting started.