Frontend Dad Blog.

Typescript Generics

Cover Image for Typescript Generics

I've been writing a ton of Typescript lately. It's lead me to be much more thoughtful in creating and composing code. The concept of generics took me a little while to get my head around. At first, it seems a bit counterintuitive. Why would we want anything to be "generic" if the entire point of Typescript is to implement a degree of safety and strictness? It turns out that generics make a ton of sense once you understand their application and justification. Read on for my explanation.

Background: To the Docs!

The Typescript official documentation is great. Let's take a look at the explanation of generics given there:

A major part of software engineering is building components that not only have well-defined and consistent APIs, but are also reusable. Components that are capable of working on the data of today as well as the data of tomorrow will give you the most flexible capabilities for building up large software systems.

In languages like C# and Java, one of the main tools in the toolbox for creating reusable components is generics, that is, being able to create a component that can work over a variety of types rather than a single one. This allows users to consume these components and use their own types.

This makes sense. We want our code to be resuable and extendable. How can we design code that works with the data of today but also the data of tomorrow, but safely? The answer is Typescript Generics.

Implementation: You Are Who You Say You Are, Aren't You?

Let's say we wanted to write a function in Typescript that simply tells us the primitive type of the argument supplied. Contrived, yes, but it's a good example all the same.

function nameThatPrimitive(arg: string):string {
  return typeof arg;
}

// nameThatPrimitive("foo") => "string"
// nameThatPrimitive(2) ERROR!! Wrong type of argument supplied...

Alright, that doesn't work. And it's obvious why. We've written the function signature such that it can only accept a string. So I guess we could extend it with some union types...

function nameThatPrimitive(arg: string|number):string {
  return typeof arg;
}

// nameThatPrimitive("foo") => "string"
// nameThatPrimitive(2) "number"

That's fine... but what if down the road, in the future, we wanted to run a different type through this without having to just extend the untion? Isn't there a way to make this a little more... generic? Indeed there is.

function nameThatPrimitive<Type>(arg: Type):string {
  return typeof arg;
}

// nameThatPrimitive("foo") => "string"
// nameThatPrimitive(2) "number"
// nameThatPrimitive({}) "object"
// nameThatPrimitive(true) "boolean"

Oooooh. Nice. This function can handle anything. Ok, but isn't there a simpler way to do this in Typescript? Can't we just throw the any type at it and call it a day?

function nameThatPrimitive(arg: any):any {
  return typeof arg;
}

// nameThatPrimitive("foo") => "string"
// nameThatPrimitive(2) "number"
// nameThatPrimitive({}) "object"
// nameThatPrimitive(true) "boolean"

We can, but now we've essentially thrown away our type safety. any is an escape hatch, and we should avoid it. Let's unpack the example with generics.

function nameThatPrimitive<Type>(arg: Type):string {
  return typeof arg;
}

// nameThatPrimitive("foo") => "string"
// nameThatPrimitive(2) "number"
// nameThatPrimitive({}) "object"
// nameThatPrimitive(true) "boolean"

A few things jump out here. The <> next to the function declaration hints to Typescript that we are going to use a generic here. It's called a type parameter. The convention is to use the syntax <Type> or simply <T> to tell typescript, "Hey, this function is intended to operate on a generic". Furthermore, we indicate that the argument should match the same generic type being operated on. Since it's generic, it can be anything. If we wanted to enforce the return type of the function matching the passed in generic, we could do that too (but that wouldn't work in this example, since always want a string.)

Passing Generic Type Parameters When Calling Functions

You'll sometimes see the <> syntax when functions are being called as well. This is a way to tell Typescript to enforce that the calling function is passing in arguments of the right type.

function nameThatPrimitive<Type>(arg: Type):string {
  return typeof arg;
}

// nameThatPrimitive<string>("Andy") => "string"
// nameThatPrimitive<number>("Andy") ERROR - you've passed in the wrong type!

Being this explicit with arguments usually isn't necessary, but it illustrates the concept.

Interfaces and Generics

Generics can also be used when declaring interfaces. Maybe you have some sort of program where a user's role can be expressed as a string, or a number.


interface User<x> {
  role: x,
  name: string
}

const andy: User<string> = {
  name: "andy",
  role: "admin"
}

const charlotte: User<number> = {
    name: "Charlotte",
    role: 1
}

One Last Example

Let's define a function that's intended to take an array of anything and return the member of that array at a given index. I'll use the <T> shorthand here as well.

function getMemberAtIndex<T>(list: T[], index: number):T {
  return list[index]
}

const foo = getMemberAtIndex([1,2,3], 2) // 3
const bar = getMemberAtIndex([1,2,false], 2) // false

The syntax here is trickier, but we are essentially saying, "Hey Typescript, this function is intended to act on a generic. We are going to pass a list of data of any type here, and the output should match a member of the input list".

Wrap Up

The above examples really just scratch the surface of generics in Typescript. They can be extended to Object Oriented patterns with classes, and expanded when manipulating keys and values within mapped objects. The takeaway here should be that it's possible to safely write functions that can act on different types of data.

Sources

Official Typescript Docs