Generics in TypeScript

Christopher T.

March 5th, 2020

Share This Post

If you've been learning JavaScript for a while and you've never had a hands on experience with TypeScript, I highly recommend you to get started with TypeScript as soon as possible and see if it's something you would like to start using to develop your apps. Integrating TypeScript into your development flow will bring great benefits like writing better code and avoiding bugs from occurring before they even get the chance to occur.

And if you've been using TypeScript, you might find generics to be a confusing concept especially if you're new to TypeScript. However, after understanding a little more about them you will come to realize that they actually aren't that difficult to understand.

This post aims to go over generics in TypeScript and talk about what they are, how they work, and why we even need them.

Generics

Personally implementing and using generics in TypeScript is both challenging and fun. They can easily feel quite complicated when written but when it's implemented correctly from code to code that strengthens the type safety of your program it enables you to feel more at ease doing things in code you normally wouldn't be doing if you didn't have TypeScript to ensure the safeness of your code.

What are they?

One of the strongest concepts today in modern apps is the concept of reusable components in software. These "components" are usually used in the context of parts of a user interface, but people use the term interchangeably for other things like functions or modules on the page.

When something can be reused it means they can be used for more than one purpose or for multiple situations.

Why?

Generics can be completely useless if you don't use them (well, no duh). But when they are used they not only ensure type safety--they can also allow you to make better decisions and give more ideas on how you can use type functions for example.

How do they work?

So how do they help us, and why do they have anything to do with "reusables"?

Lets say you're building a function that, when called, calls typeof on the argument and returns the argument. Totally useless but that's not the point.

Let's see how this might look like when written in TypeScript:

function logAndReturnIt(
  valueThatIsGoingToBeLoggedAndReturnedFromAUselessFunction: string,
): string {
  console.log(typeof valueThatIsGoingToBeLoggedAndReturnedFromAUselessFunction)
  return valueThatIsGoingToBeLoggedAndReturnedFromAUselessFunction
}

Despite how useless this function is, we can see that it put a type of string on the argument. Now you have the option of using any kind of string with this function when using TypeScript:

const greeting = 'Good morning'

logAndReturnIt(greeting) // logs 'Good morning'
logAndReturnIt('cat') // logs 'cat'

If we were to have it support another data type we would have to change the code like so:

function logAndReturnIt(
  valueThatIsGoingToBeLoggedAndReturnedFromAUselessFunction: null,
): null {
  console.log(typeof valueThatIsGoingToBeLoggedAndReturnedFromAUselessFunction)
  return valueThatIsGoingToBeLoggedAndReturnedFromAUselessFunction
}

logAndReturnIt(null) // valid

logAndReturnIt(5) // invalid
// TypeScript error: Argument of type '5' is not assignable to parameter of type 'null'.ts(2345)

In practice, when we develop apps and we use this function, we're capped to only using it with null.

This means that the function isn't reusable. So let's make this function more reusable--and we have a couple of options to do that.

The first option is to use the any type that we all once loved to use when we were first learning TypeScript:

function logAndReturnIt(
  valueThatIsGoingToBeLoggedAndReturnedFromAUselessFunction: any,
): any {
  console.log(typeof valueThatIsGoingToBeLoggedAndReturnedFromAUselessFunction)
  return valueThatIsGoingToBeLoggedAndReturnedFromAUselessFunction
}

logAndReturnIt(null) // valid
logAndReturnIt(5) // valid
logAndReturnIt('abc123') // valid
logAndReturnIt(undefined) // valid
logAndReturnIt(false) // valid

Using the any type gave us the ability to work with any data type, which helped us achieve our goal. The caveat with this approach is that we actually lost the benefits of type safety. What this means is that even if we pass in weird values like an error instance, it will still be considered okay for the compiler because TypeScript will handle the responsibility to you.

There's a more powerful way to strongly type the function, making them type-safe and more reusable. You can declare a new generic type and use that instead, like this:

function logAndReturnIt<T>(
  valueThatIsGoingToBeLoggedAndReturnedFromAUselessFunction: T,
): T {
  console.log(typeof valueThatIsGoingToBeLoggedAndReturnedFromAUselessFunction)
  return valueThatIsGoingToBeLoggedAndReturnedFromAUselessFunction
}

The syntax may look a little weird and intimidating but it's actually very easy to understand. In JavaScript, you can declare variables and use them afterwards like this:

let fruit = 'apple'

function sayStuff(stuff) {
  window.alert(stuff)
}

sayStuff(`i love eating ${fruit}s`) // implicit
// or
sayStuff < number > 500 // explicit

In the case of our type declaration example, the concept is actually the exact same. We declared a generic type T by enclosing it in arrows, located right before the first parenthesis:

generic type declaration in typescript

result asasd in vscode

Now when the function was used you can see that we gained a better way to re-use the function while being protected by the TypeScript linter for any wrongdoings.

Also, it's worth noting that just like with normal JavaScript functions, you can declare many types as you want (as with arguments to JavaScript functions), so you aren't restricted to just one type declaration:

function combine<T, W>(arg1: W, arg2: T): T {
  return arg2
}

Your TypeScript compiler will infer that whatever type you pass to arg2 the return type will be set to that:

typescript generic type infer return value

So instead of having to explicitly declare the direct type like this:

function combine(arg1: string, arg2: string): string {
  return arg2
}

You're restricted to using it only to work with strings:

vscode constrained string type declaration

Reusing functions while letting TypeScript keep you in check

The more generic way is better in reusing functions because you can capture the type you pass into it and let TypeScript help you to always keep you in check when using them afterwards depending on what you gave the function to work with.

This is where generics really become a lot more useful. You know when you hear people talk about TypeScript and how it just helps you "avoid creating bugs before they happen"? This is true, and using generics is one way that proves that really well:

function something<T, K extends keyof T>(arg1: T, arg2: K) {
  //
}

Lets say we want to work with this function. We see that it declared type T and type K that is a key of type T. We assigned type T to arg1, and type K to arg2.

We know now that when we use this function, the first argument is most likely going to be some kind of type that can be accessed by some index or property while the second argument might be some type of string or number. We know this because K extends keyof T means that K is a property/index/key of T.

We can use this function like so:

// Completely valid -- TypeScript will not complain
const result = something({ goodwill: 'a' }, 'goodwill')

Since goodwill is a property of the object passed to the first argument, it's completely valid. We gave it a strong type object like { goodwill: string } to work with.

TypeScript keeps us in check if we try to use it wrongly:

const result = something({ goodwill: 'a' }, 'toString')

generic wrong use in generic type

That's incredibly powerful since it prevents us from bugs before they even get a chance to occur!

To prove that we can reuse the function in several ways while holding onto type safety, here are some more examples:

// Perfectly valid because in JavaScript you can access characters in a string by index
// example:
//    const x = 'coffee'
//    const letterF = x[3]   // result: 'f'
const result = something('hello', 50)
// NOT valid because in JavaScript you can't access characters in a string by a string
const result = something('hello', '50')

Generics with classes

Generic types can be applied to functions, but it can also be done with classes--which is really useful especially for consumers of your code.

You can declare types like type <T> like how we're able to do with functions. However, since the syntax is different it looks a bit different:

class SomeElement<T> {
  private element: T

  constructor(element: T) {
    this.element = element
  }

  getElement() {
    return this.element
  }
}

const elem = document.createElement('button')

const button = new SomeElement<HTMLButtonElement>(elem)

When you use button after it initializes the instance, TypeScript will help keep you safe by always remembering that the element property is a button element, therefore it should only be restricted to a button's attributes, methods, properties, etc.


Tags


Subscribe to the Newsletter
Get continuous updates
© jsmanifest 2020