David Dunn

Senior Software Developer


Back to Blog

The power of Generics in TypeScript - A Primer

6 min read
typescript

Most developers meet generics through Array<T> and Promise<T> which are two of the most common instances when it comes to examples. To start the journey into the world of Generics we will traverse quickly through Arrays and then promises and ended at the power of inferring types from Generics.

const data: Array<string | number> = ["123", 23, 24, "1.23"];

As we all know, at least in languages like JavaScript, arrays can have elements of different types. It is perfectly valid to have an array that contains strings, numbers, objects and booleans all in the same array. Admittedly, uncommon in the real-world… I hope anyway!

When creating an array, TypeScript can usually infer the type even when elements are of different types:

const newArray = [{ message: "hello world" }, 23, "hello world", true];

// newArray: (string | number | boolean | { message: string; })[]

But what about when a function needs to accept an array of multiple types? Or an API returns such an array? In these instances TypeScript can not infer the types so we need another way to handle such instances in a type safe way. Which, of course, is generics.

type Player = {
  name: string;
  score: Array<number | string>;
};

function printPlayerInfo(player: Player) {
  console.log(`Player Name: ${player.name}`);
  const totalScore = player.score.reduce((acc: number, curr) => {
    if (typeof curr === "number") {
      return acc + curr;
    } else {
      return acc + parseFloat(curr);
    }
  }, 0);
  console.log(`Total Score: ${totalScore}`);
}

The typeof keyword is used to narrow types, a powerful feature in TypeScript. If you’re unsure how this works then check out my post on Narrowing Types

By using generics we’re able to ensure we perform correct operations on a value based on its type using type narrowing so we can correctly parse the string values into numbers and perform the addition.

Using generics with Arrays is commonly how most developers first encounter generics. The other… Promises.

Generics and the Promise

type Ok<T> = { ok: true; data: T };

So what is T? and why do most examples use T?

To start, T is a way to denote a generic type. Well, actually the Ok<T> is how we define a generic type, T is what we can use to assign the generic type in the type body, data: T.

So really, T is a variable name for the generic type and like any variable the name can be whatever we want:

type Ok<TData> = { ok: true; data: TData };

As for the why, T has been the standard option for Types for many years and it isn’t limited to TypeScript. C++, Java and many others follow the convention of using the single-letter T for "Type".

Generics can be used to compose new types:

type Ok<T> = { ok: true; data: T };
type Err = { ok: false; status: number; message: string };
type Result<T> = Ok<T> | Err;

We can also create functions that accept generic values:

type Ok<T> = { ok: true; data: T };
type Err = { ok: false; status: number; message: string };
type Result<T> = Ok<T> | Err;

async function getJSON<T>(url: string): Promise<Result<T>> {
  const res = await fetch(url);
  if (!res.ok)
    return { ok: false, status: res.status, message: res.statusText };
  return { ok: true, data: (await res.json()) as T };
}

Then when we use this function we can provide the type and enjoy the benefits of type safety:

type User = { firstName: string; lastName: string; age: number };
const userData = getJSON<User>("https://api.example.com/data");

// jsonData is typed as Promise<Result<User>>

So we now have a generic getJson function that can be used throughout a project to fetch data of any shape in a type-safe way. This is the real power of generics.

Inferring types + Overloads = Powerful typing duo

type ResponseOf<S extends number, T> = S extends 200 | 201
  ? { status: S; body: T }
  : S extends 204
  ? { status: S; body: null }
  : { status: S; body: { error: string } };

function respond<S extends number, T>(
  status: S,
  body: ResponseOf<S, T>["body"]
) {
  return { status, body } as ResponseOf<S, T>;
}

respond<204, null>(204, null);
respond<200, { id: number }>(200, { id: 1 });
respond<400, { error: string }>(400, { error: "Bad request" });

There are no rules around having multiple generic values within a single type, both S and T are generic values here. We can also allow TypeScript to infer the types of the generic rather than explicity stating them and in many cases TypeScript can do this well:

const result1 = respond(204, null); // result1: { status: 204; body: null; }
const result2 = respond(400, { error: "Bad request" }); // result2: { status: 400; body: { error: string }; }
const result3 = respond(200, { id: 1 }); // result3: { status: 200; body: unknown; }

As we can see TypeScript is able to correctly infer result1 and result2 but it struggles to infer result3 and this is because TypeScript can’t resolve the type of T from ResponseOf<S, T>["body"] because T appears inside a conditional type that depends on S.

This is a common problem and the most common solution is overloads:

function respond<S extends 200 | 201, T>(
  status: S,
  body: T
): { status: S; body: T };

function respond(status: 204, body: null): { status: 204; body: null };

function respond(
  status: number,
  body: { error: string }
): { status: number; body: { error: string } };

function respond<S extends number, T>(
  status: S,
  body: ResponseOf<S, T>["body"]
) {
  return { status, body } as ResponseOf<S, T>;
}

As we can see overloads here simply means providing typed function declarations prior to the actual function implementation. TypeScript is then able to work out which overload to use based on the types of the input to the function.

So now we get full inferred type safety:

const result3 = respond(200, { id: 1 }); // result3: { status: 200; body: { id: number; }; }

We always want to, where possible, allow TypeScript to infer the types for us. Not only is this faster but easier to maintain and scale as well. Why? Well rather than having to explicitly define types every time we use a generic we just let TypeScript do the hard work for us. This means if ever we needed to change the type we’d only need to change the generic itself and not have to go through every usage of the generic and update the manual types.

Conclusion

It is very easy to reach for any when writing TypeScript and sometimes, when speed is of the essence, many developers will rely heavily on any which defeats the purpose of using TypeScript. Understanding Generics and how to quickly put together generic types is incredibly important for the maintainability of a codebase.

We have barely scratched the surface here of what Generics are capable of, this was more of an intro and in a future post we will go deeper into the world of generics and really understand how developers use these to create true magic.