Skip to main content

Generics in TypeScript

4 min read

Older Article

This article was published 9 years ago. Some information may be outdated or no longer applicable.

Languages like C# and Java have used generics for years to build reusable components. TypeScript brings the same capability to JavaScript.

So what are generics, exactly? Think of it this way. Say we want to write some code that randomly picks a number for a card game. Easy enough:

function pickNumber(numbers: number[]): number {
  const randomIndex = Math.floor(Math.random() * numbers.length);
  return numbers[randomIndex];
}

const numbers = [...Array(13).keys()];
const pickedNumber = pickNumber(numbers);
console.log(pickedNumber);

Please note that in order for this code to compile properly with tsc, use the --target es6 flag, otherwise a bunch of errors will appear

Since we’re building a card game, we also want to randomly pick a suit (diamonds, clubs, hearts, spades). So we’d need another function:

function pickSuit(suits: string[]): string {
  const randomIndex = Math.floor(Math.random() * suits.length);
  return suits[randomIndex];
}

const suits = ['diamonds', 'clubs', 'hearts', 'spades'];
const pickedSuit = pickSuit(suits);
console.log(pickedSuit);

Both functions do the same thing. They just operate on different data types. Every time we need to pick something new, we’d have to write yet another function. Not ideal.

Generics to the rescue

Generics let us create a single function that doesn’t care about data types. It’ll accept whatever you throw at it.

If you already know TypeScript, you might reach for the any keyword:

function picker(args: any[]): any {
  const randomIndex = Math.floor(Math.random() * args.length);
  return args[randomIndex];
}

const suits = ['diamonds', 'clubs', 'hearts', 'spades'];
const numbers = [...Array(13).keys()];
const pickedNumber = picker(numbers);
const pickedSuit = picker(suits);
console.log(`Your card is: ${pickedNumber} ${pickedSuit}`);

This works. But there’s a fundamental problem: we lose type information. That can lead to some quietly weird behaviour:

function picker(args: any[]): any {
  const randomIndex = Math.floor(Math.random() * args.length);
  return args[randomIndex];
}

const suits = ['diamonds', 'clubs', 'hearts', 'spades'];
const numbers = [...Array(13).keys()];
const pickedNumber = picker(numbers);
const pickedSuit: number = picker(suits); // pickedSuit is really a string
console.log(`Your card is: ${pickedNumber} ${pickedSuit}`);

Here, pickedSuit is cast as a number, but we know it should be a string from an array of strings. The code compiles without complaint. No error, ever.

To keep type safety and reusability, we write a generic function:

function picker<T>(args: T[]): T {
  const randomIndex = Math.floor(Math.random() * args.length);
  return args[randomIndex];
}

The angle brackets (<>) mark the generic. Notice how T flows through the entire function signature.

Note that we can use any identifier for generics, <T> seems to be the de facto standard

Our picker() function takes an argument of any type and returns that same type. Pass in an array of strings, get a string back. Pass in numbers, get a number. And now that broken example from before throws a proper error:

const suits = ['diamonds', 'clubs', 'hearts', 'spades'];
const numbers = [...Array(13).keys()];
const pickedNumber = picker(numbers);
const pickedSuit: number = picker(suits); // Type 'string' is not assignable to type 'number'.
console.log(`Your card is: ${pickedNumber} ${pickedSuit}`);

Generics and Constraints

The previous examples worked fine. But sometimes TypeScript can’t make assumptions about the type being passed in. Consider this:

function game<T>(args: T): T {
  return args.name; // Property 'name' does not exist on type 'T'
}

We want to pass in a game name and return it. The problem: T could be an array, a string, a number. None of those have a .name property, and TypeScript tells us so. It can’t predict what arguments we’ll feed to the generic function, but we can give it a hint.

Constraints let us specify what shape the data must have. We create an interface and tell our generic to extend it:

interface Game {
  name: string;
}

function game<T extends Game>(args: T): string {
  return args.name;
}

const myGame: Game = {
  name: 'solitaire',
};
console.log(`My favourite game is : ${game(myGame)}`);

Because game() now extends the Game interface, we can safely access the name property on our arguments.