David Dunn

Senior Software Developer


Back to Blog

Deep Dive Into Generics In TypeScript

9 min read
typescript

Generics are an incredibly powerful feature in TypeScript. In a previous post we went over some of the basics of Generics. Now we will dive deeper and unlock some of the true power of this feature of TypeScript.

In the The power of Generics in TypeScript - A Primer post we discussed how to type arrays and promises using Generics. We also touched on how we can rely on type inference when using generics and even discussed when this doesn’t work.

We’re now going to dive deeper into generics to truly understand them and look at how we can design generic APIs we can use, and importantly reuse.

Generic mental model

We can think of generics as functions but instead of accepting runtime values a generic accepts a type.

Let’s use this simple example to showcase this:

type Box<T> = { value: T };

type StringBox = Box<string>; // { value: string }
type NumberBox = Box<number>; // { value: number }

And if we look at an actual function:

function Box(T) {
  return { value: T };
}

const StringBox = Box("Hello");
const NumberBox = Box(42);

Syntactically they look very simlar, and they’re even doing a similar thing. Taking some input, a type, and returning some value, a new type.

Now the above example is very simple and it is unlikely that we’d ever use it in a real application but if we combine the two, we can create a generic function.

function identity<T>(value: T): T {
  return value;
}

const a = identity("Hello"); // a is typed as string
const b = identity(42); // b is typed as number

When creating a generic function works in the same way, we pass values into the function at runtime and allow TypeScript to infer the types. This is important, when designing generics we always want to try and let inference do the work.

We only want to force the consumer to explicitly provide <T> when it is absolutely necessary.

We also want to make use of constraints to keep things safe, but what does this mean? Well lets take a look.

Constraints with Generics

If we look at the above example again:

function identity<T>(value: T): T {
  return value;
}

T could be anything at all, but what happens if we wanted to do something like this:

function lengthOf<T>(value: T) {
  return value.length;
}

We know that not all types have a length property, for example if value is a number then the lengthOf function would error.

In this case T is too vague, we need to reduce its scope or ‘constrain’ it by saying ‘T must have a length property’. So how do we do that? Well, it is actually fairly simple:

function lengthOf<T extends { length: number }>(value: T) {
  return value.length;
}

Now if we attempt to provide a value that doesn’t have the length property, like a number, we’d get an error.

lengthOf("hello"); // good (string has length)
lengthOf([1, 2, 3]); // good (array has length)
lengthOf(123); // error (number does not have length)

Default Generic Parameters

If we take a look at this example:

type ApiResponse<TData, TError> =
  | { ok: true; data: TData }
  | { ok: false; error: TError };

It is a perfectly valid ApiResponse type that we can use like so:

type UserResponse = ApiResponse<User, ValidationError>;

But the issue here is the consumer of this ApiResponse type will always have to provide both the TData type and the TError type to the generic ApiResponse type. This can be annoying and sometimes messy because:

So how can we work around this to make the ApiResponse type nicer to use? Well… As we discussed at the start of this post generics behave a lot like functions in TypeScript and in functions we’re able to provide default values for a parameter so the consumer of the function doesn’t need to provide that value. We can do the exact same with generics:

type ApiResponse<TData = unknown, TError = { message: string }> =
  | { ok: true; data: TData }
  | { ok: false; error: TError };

Now if the consumer of ApiResponse doesn’t provide a TData or TError the default types would be used which now means we can use the ApiResponse like so:

type DataResponse = ApiResponse; // TData = unknown, TError = { message: string }
type PostResponse = ApiResponse<Post>; // TData = Post, TError = { message: string }
type UserResponse = ApiResponse<User, ValidationError>; // TData = User, TError = ValidationError

Why unknown instead of any?

Well, if we think about it, in cases where we don’t specify TData there is a good chance we don’t know what the data is. So it is unknown to us and by using unknown it forces us to prove what the type is before we try to use it. This helps prevent bugs around assuming TData is a specific type.

We could use any but then we’re throwing away all type safety and allowing bugs to creep in.

So we should always reach for unknown in defaults where possible.

Inference: TypeScript Deciding What T Is

Where possible we always want to let TypeScript do the hard work it creates a more ergonomic API design in most cases. This is what inference is, TypeScript will attempt to work out the type of T by looking at the values provided to the generic.

Inference works really well with function parameters:

function wrap<T>(value: T) {
  return { value };
}

const x = wrap({ a: 1 }); // { value: { a: number } }

But, as we may expect, inteference can’t be fully relied on in all cases. Inference gets weaker with:

Type widening is one of the most common examples we’re likely to run into.

So, how do we solve it?

We can stop a string literal from widening by using as const:

const roles = ["admin", "user"]; // string[]

const roles2 = ["admin", "user"] as const; // readonly ["admin", "user"]

type Role = (typeof roles2)[number]; // "admin" | "user"

With as const we can ensure that our, relatively simple, types do not get widened and preserve important type information but what about more complicated types, such as:

type RouteConfig = Record<string, { path: string; auth: boolean }>;

const routes: RouteConfig = {
  home: { path: "/", auth: false },
  account: { path: "/account", auth: true },
};

In the above example, routes.home.path would be typed as string but what if we wanted to keep it as a string literal, typed as "/"?

Well, this is where we can use the satifies operator:

const routes = {
  home: { path: "/", auth: false },
  account: { path: "/account", auth: true },
} satisfies RouteConfig;

When we use satifies it will check the shape of our object to ensure it matches the shape of RouteConfig but it will also retain the inferred literals, so routes.home.path would be typed as "/".

Now that we have a better understanding of generics we can look at some real-world use cases we can use in our projects.

A Typesafe getJSON<T>

In the previous generics post we built a generic getJSON<T> that returned Promise<Result<T>> which was a great introduction to generics but there is a problem with that implementation:

data: (await res.json()) as T;

We performed the above cast which doesn’t actually validate anything. If the API returns something random TypeScript will still trust it. So how do we improve this?

Well we can pair generics with runtime validation:

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

type Guard<T> = (value: unknown) => value is T;

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

  const raw: unknown = await res.json();
  if (!isT(raw))
    return { ok: false, status: 500, message: "Invalid response shape" };

  return { ok: true, data: raw };
}

Where instead of just accepting a parameter typed as T we instead accept a function, a predicate. This function accepts a value of unknown type, which makes sense because JSON is typically provided from some third pary source and is therefore untrusted.

This function, or predicate as its called in TypeScript, is a conditional that validates that the provided value is typed in a way we expect.

This may seem confusing, but if we look at how we use this:

type User = { id: string; name: string };

const isUser = (x: unknown): x is User => {
  return (
    typeof x === "object" &&
    x !== null &&
    "id" in x &&
    "name" in x &&
    typeof (x as any).id === "string" &&
    typeof (x as any).name === "string"
  );
};

const result = await getJSON<User>("/api/user", isUser);
if (result.ok) {
  result.data.name;
}

So in the above example isUser is the predicate which checks that all expected fields exist, and those fields are of a particular type and if it is then returns true, otherwise returns false.

Now, in most applications a schema library, like Zod, is used and in those cases we’d infer the type from the schema itself rather than building a generic type manually like we did in the predicate above.