Quick guide to Generics in TypeScript

Quick guide to Generics in TypeScript

Or what do these <T>, <K> even mean?

ยท

5 min read

TypeScript is a powerful language, and today we are going to dive into one of the most important and fundamental features that it provides. Generics allows us to pass types as parameters to almost any structure: type, interface, function, class or even React-component (which is a function) to make them work with any type of data. This helps to create a truly reusable piece of code, remove duplicates, and improve your code flexibility.

Basic Example

Assume that we have some fruits and we want to make them grow:

type Fruit = '๐ŸŽ' | '๐Ÿ' | '๐ŸŠ' | '๐Ÿ‹' | '๐Ÿ‘';

function grow(fruit: Fruit): Fruit[] {
  return Array(3).fill(fruit); // One year later...
}

const harvest = grow('๐Ÿ‹'); // ['๐Ÿ‹','๐Ÿ‹','๐Ÿ‹']: Fruit[]

Works well, doesn't it? We have our harvest which has the Fruit[] type - the same as we specified as the return type of grow function. But what if we want to make our function reusable and to grow some vegetables as well? The first solution may sound simple: copy that!

type Vegetable = '๐Ÿ…' | '๐Ÿ†' | '๐Ÿฅฆ' | '๐Ÿฅ’' | '๐Ÿฅ”';

function grow(vegetable: Vegetable): Vegetable[] {
  return Array(3).fill(vegetable);
}

const harvest = grow('๐Ÿฅฆ'); // ['๐Ÿฅฆ','๐Ÿฅฆ','๐Ÿฅฆ']: Vegetable[]

While it is a correct solution, it is not flexible. Imagine if the next day you'll be facing a task to grow some berries, then seeds, etc. Here is where we meet Generic type parameter. With the special "angle" syntax <> we can pass a type to our function and then use it inside, almost the same as we pass function arguments, but for types:

type Fruit = '๐ŸŽ' | '๐Ÿ' | '๐ŸŠ' | '๐Ÿ‹' | '๐Ÿ‘';
type Vegetable = '๐Ÿ…' | '๐Ÿ†' | '๐Ÿฅฆ' | '๐Ÿฅ’' | '๐Ÿฅ”';

function grow<Plant>(plant: Plant): Plant[] {
  return Array(3).fill(plant)
}

const fruitHarvest = grow<Fruit>('๐ŸŠ') // ['๐ŸŠ','๐ŸŠ','๐ŸŠ']: Fruit[]
const vegetableHarvest = grow<Vegetable>('๐Ÿ†') // ['๐Ÿ†','๐Ÿ†','๐Ÿ†']: Vegetable[]

See how we've passed a desired type to the function and then used it as a type for an argument and as a function return type. Now we have a reusable function that can work with any type: Fruit, Vegetable, Berry or even string, number, Array. Generics can be used with the arrow-syntax as well:

const grow = <Plant>(plant: Plant): Plant[] => {
  return Array(3).fill(plant);
};

Default type parameter

If we don't pass any type to our function the result will have an unknown type, to fix it or if our function is commonly used with only one type we can add a default parameter type, same as you do for function argument default value using = Default:

function grow<Plant = Fruit>(plant: Plant): Plant[] {
  return Array(3).fill(plant);
}

const fruitHarvest = grow('๐ŸŠ') // ['๐ŸŠ','๐ŸŠ','๐ŸŠ']: Fruit[]

Type constraint

Sometimes we need to call a method for typed argument or get its property, but trying to do so with current implementation will result in an error. For example, we know that Fruit is actually a string, right? And a string Prototype has its own built-it methods, so let's call one of them:

function grow<Plant>(plant: Plant) {
  plant = plant.trim(); // โ›” Property 'trim' does not exist on type 'Plant'
  return Array(3).fill(plant);
}

This happens because Plant is a theoretical abstract type and the TS compiler doesn't know what it could be and what properties or methods it will have when the function will be called. So let's constrain our grow function to work only with types, that have the trim() method (string in this case):

function grow<Plant extends string>(plant: Plant) {
  plant = plant.trim(); // โœ… Works now!
  return Array(3).fill(plant);
}

We use extends keyword to point to TS that every Plant is a string or at least extends it (=== have all string properties and methods). Other important use-case for this is that as we've restricted the type parameter, we can no longer pass an invalid non-string argument there:

// You can't grow numbers:
const numberHarvest = grow<number>(2); 
// โ›” Type 'number' does not satisfy the constraint 'string'

Interface, Class and Type

We can easily use Generic type parameters for other JS structures via angle-syntax:

interface Garden<Plant> {
  plants: Plant[]
}

class Garden<Plant> {
  plants: Plant[]

  constructor(plants: Plant[]) {
    this.plants = plants
  }
}

Same goes for creating any custom types:

type Plants<Plant> = Plant[]
type Fruits = Plants<Fruit> // Fruit[]
type Vegetables = Plants<Vegetable> // Vegetable[]

The code above might look pretty simple, but generic types are commonly used to create various helper types for your app, take a look to some of the built-in TypeScript helpers:

// Exclude from T those types that are assignable to U
type Exclude<T, U> = T extends U ? never : T;

// Construct a type with the properties of T except for those in type K.
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

Usage with React Component

As React component is a function (or class, which is eventually a function in JS) we can power it up with generic type parameters as well to make it reusable.

interface Props<Plant> {
  plants: Plant[]
}

const Garden = <Plant extends string>({ plants }: Props<Plant>) => {
  return <>{plants.map(plant => plant)}</>
}

Please note, that you can't use React.FC syntax for that. If you need to access the children property just wrap your Props with PropsWithChildren helper type from React as follows:

const GardenWithChildren = <Plant extends string>({ plants, children }: PropsWithChildren<Props<Plant>>) => {
  return (
    <>
      {children}
      {plants.map(plant => plant)}
    </>
  )
}

More read:

Generics in TypeScript Handbook

Hope you enjoyed this guide, stay tuned for more.

ย