All Articles

TypeScript Generics in 3 Easy Patterns

Matt Pocock
Matt PocockMatt is a well-regarded TypeScript expert known for his ability to demystify complex TypeScript concepts.

What you might think of as generics in TypeScript is actually three separate concepts:

  • Passing types to types
  • Passing types to functions
  • Inferring types from arguments passed to functions
// 1. Passing types to types
type PartialUser = Partial<{ id: string; name: string }>;

// 2. Passing types to functions
const stringSet = new Set<string>();

// 3. Inferring types from arguments passed to functions
const objKeys = <T extends object>(obj: T): Array<keyof T> => {
  return Object.keys(obj) as Array<keyof T>;
};

const keys = objKeys({ a: 1, b: 2 });
//    ^? const keys: ("a" | "b")[]

Let's start with passing types to types.

In TypeScript, you can declare a type which represents an object, primitive, function - whatever you want, really.

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

export type ToString = (input: any) => string;

But let's say you need to create a few types with a similar structure. For instance, a data shape.

The code below isn't very DRY - can we clean it up?

type ErrorShape = {
  message: string;
  code: number;
};

type GetUserData = {
  data: {
    id: string;
    name: string;
  };
  error?: ErrorShape;
};

type GetPostsData = {
  data: {
    id: string;
    title: string;
  };
  error?: ErrorShape;
};

type GetCommentsData = {
  data: {
    id: string;
    content: string;
  };
  error?: ErrorShape;
};

If you're OOP-inclined, you could do this using a reusable interface like this:

interface DataBaseInterface {
  error?: ErrorShape;
}

interface GetUserData extends DataBaseInterface {
  data: {
    id: string;
    name: string;
  };
}

interface GetPostsData extends DataBaseInterface {
  data: {
    id: string;
    title: string;
  };
}

interface GetCommentsData extends DataBaseInterface {
  data: {
    id: string;
    content: string;
  };
}

But it's more concise to create a 'type function', which takes in the type of data and returns the new data shape:

// Our new type function!
type DataShape<TData> = {
  data: TData;
  error?: ErrorShape;
};

type GetUserData = DataShape<{
  id: string;
  name: string;
}>;

type GetPostsData = DataShape<{
  id: string;
  title: string;
}>;

type GetCommentsData = DataShape<{
  id: string;
  content: string;
}>;

This syntax is important to understand, because it'll come up later!

The angle brackets in <TData> tell TypeScript that we want to add a type argument to this type. We can name TData anything, it's just like an argument to a function.

This is a generic type:

// Generic type
type DataShape<TData> = {
  data: TData;
  error?: ErrorShape;
};

// Passing our generic type
// another type
type GetPostsData = DataShape<{
  id: string;
  title: string;
}>;

Generic types can accept multiple type arguments. You can provide defaults to type arguments, as well as set constraints so only certain types can be passed.

But it's not just types that you can pass types to:

const createSet = <T>() => {
  return new Set<T>();
};

const stringSet = createSet<string>();
// ^? const stringSet: Set<string>

const numberSet = createSet<number>();
// ^? const numberSet: Set<number>

That's right- just like types can accept types as arguments, so can functions.

In this example, we add a <T> before the parentheses when we declare createSet. We then pass that <T> manually into Set(), which itself lets you pass a type argument:

export const createSet = <T>() => {
  return new Set<T>();
}

That means that when we call it, we can pass a type argument of <string> to createSet.
Now we end up with a Set that we can only pass strings to.

const stringSet = createSet<string>();

// Argument of type 'number' is not assignable to parameter of type 'string'.
stringSet.add(123);

This is the second thing that people mean when they talk about generics: manually passing types to functions.

You'll have seen this if you use React, and you've needed to pass a type argument to useState:

const [index, setIndex] = useState<number>(0);
// ^? const index: number

But, you'll also have noticed another behavior in React where in some cases, you don't need to pass the type argument in order for it to be inferred:

const [index, setIndex] = useState(0);
// ^? const index: number

// WHAT IS THIS SORCERY

To see what's going on here, let's look at our createSet function again. Notice that it takes in no actual arguments- only type arguments:

export const createSet = <T>() => {
  return new Set<T>();
}

What would happen if we change our function so that it accepts an initial value for the set?

We know that the initial value needs to be the same type as the Set, so let's type it as T:

export const createSet = <T>(initial: T) => {
  return new Set<T>([initial]);
}

Now, when we call it, we'll need to pass in an initial, and that'll need to be the same type as the type argument we pass to createSet.

const stringSet = createSet<string>("matt");
//    ^? const stringSet: Set<string>

// Argument of type 'string' is not assignable to parameter of type 'number'.
const numberSet = createSet<number>("pocock");

Here's the magical thing. TypeScript can infer the type of T from initial.

In other words, the type argument will be inferred from the runtime argument.

const stringSet = createSet("matt");
//    ^? const stringSet: Set<string>
const numberSet = createSet(123);
//    ^? const numberSet: Set<number>

You can examine this by hovering over one of the createSet calls. You'll see that <string> is being inferred when we pass it a string:

// hovering over createSet
const createSet: <string>(initial: string) => Set<string>

The same is true in useState (see the useState<number> syntax in the tooltip):

// hovering over useState
useState<number>(initialState: number | (() => number)): [number, Dispatch<SetStateAction<number>>]

It's also true for this objKeys function that includes some extra goodness as well:

  • We constrain T to be an object so it can be passed to Object.keys (which only accepts objects)
  • We force the return type of Object.keys to be Array<keyof T>
const objKeys = <T extends object>(obj: T) => {
  return Object.keys(obj) as Array<keyof T>;
};

To sum it all up, what you think of as 'generics' are actually three different patterns:

  • Passing types to types - DataShape<T>
  • Passing types to functions - createSet<string>()
  • Inferring types from arguments passed to functions - useState(0)

If this felt like it went over your head, check out the free TypeScript Beginners tutorial.

On the other hand, if you want to go deeper into patterns, check out the Total TypeScript Pro Workshops!

Matt's signature

Share this article with your friends