How to wrap your head around Typescript generics

Typescript code on a laptop screen

When I first started learning Typescript, one of the concepts that took me a while to fully grasp was generics. But once it finally "clicked", I realized how powerful and useful they can be. In this article, I‘ll break down what generics are, why you would want to use them, and provide some real-world examples to help solidify your understanding.

What are generics?

Put simply, generics allow you to write reusable code components that can work with a variety of types rather than a single type. They are a key feature in Typescript for building scalable, DRY (don‘t repeat yourself) code.

With generics, you write the code once, and then it can adapt to the specific types it needs to handle at compile time. The alternative would be to write multiple variations of the same code for different types, or to use the any type which disables a lot of Typescript‘s helpfulness.

How do generics work?

The key to understanding generics is the concept of type parameters. A type parameter is a special kind of variable that works on types rather than values. In Typescript, type parameters are denoted with the <> syntax.

When you declare a function, class, or interface using generics, you add the type parameter in angle brackets. This type parameter acts as a placeholder that will get filled in later on when the code is actually used.

Here‘s a very simple example of a generic identity function:

function identity<T>(arg: T): T {
  return arg;
}

In this code, T is the type parameter. The identity function accepts an argument of some type T and returns a value of the same type T. The actual type T gets determined when the function is called:

let output1 = identity<string>("myString");
//       ^ = let output1: string

let output2 = identity<number>(100); 
//       ^ = let output2: number

As you can see, we can use the same identity function with different concrete types by specifying the type parameter when calling the function. Typescript then knows the specific type and can provide type-checking and IntelliSense for that usage.

A more practical example

Let‘s look at a more practical example that demonstrates the power of generics. Say we want to write a function that reverses an array. Without generics, we would need to write different versions for different array types:

function reverseArray(arr: string[]): string[] {
  return arr.reverse();
}

function reverseArray(arr: number[]): number[] {
  return arr.reverse();
}

// And so on for other array types...

With generics, we can declare a single function that handles all array types:

function reverseArray<T>(arr: T[]): T[] {
  return arr.reverse();
}

Now we can use this same reverseArray function with arrays of strings, numbers, or any other type:

let strings = ["a", "b", "c"];
reverseArray<string>(strings);
//            ^ = (method) reverseArray<string>(arr: string[]): string[]

let numbers = [1, 2, 3];
reverseArray<number>(numbers); 
//            ^ = (method) reverseArray<number>(arr: number[]): number[]

The type parameter T represents the element type of the array. When calling reverseArray, we specify the actual type in place of T, and Typescript handles the rest to give us type-safety and convenient tooling support.

Generics with interfaces and classes

In addition to functions, generics can be used with interfaces and classes. A common use case is creating interfaces for data repositories or services that work with a database.

interface Repository<T> {
  get(id: number): T;
  getAll(): T[];
  create(entity: T): void;
  update(entity: T): void;
  delete(id: number): void; 
}

Here we‘ve declared a generic Repository interface with CRUD methods. The type parameter T represents the type of the database entity. We can then implement this interface with a specific entity type:

class UserRepository implements Repository<User> {
  get(id: number): User {
    // Retrieve user from database by ID
  }

  getAll(): User[] {
    // Retrieve all users from database
  }

  create(entity: User): void {
    // Insert new user into database
  }

  update(entity: User): void {
    // Update user in database
  }

  delete(id: number): void {
    // Delete user from database by ID
  }
}

The UserRepository class implements the generic Repository interface and fills in the User type for the type parameter. This gives us type-safety and convenient auto-complete for the User type throughout the class.

We can follow the same pattern to create repositories for any other entity type (Product, Order, etc.) while reusing the common Repository interface.

Generics can also be used to create reusable class implementations. For example, here‘s how we could implement a generic Stack class:

class Stack<T> {
  private items: T[] = [];

  push(item: T) {
    this.items.push(item);
  }

  pop(): T | undefined {
    return this.items.pop();
  }

  peek(): T | undefined {
    return this.items[this.items.length - 1];
  }

  size(): number {
    return this.items.length;
  }
}

The Stack class represents a last-in first-out (LIFO) stack data structure. The type parameter T allows it to work with any type of elements. We can create stacks with different types like so:

const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
//    ^ = (method) Stack<number>.push(item: number): void

const stringStack = new Stack<string>();
stringStack.push("hello");
stringStack.push("world");
//    ^ = (method) Stack<string>.push(item: string): void

Again, the power of generics allows us to reuse the same Stack implementation with a variety of types while maintaining type-safety. The Typescript compiler will make sure we only add and retrieve elements of the correct type from each stack instance.

Generic constraints and advanced usage

In the examples so far, our generic type parameters have been totally unconstrained – they can represent any type. But sometimes we may want to limit the kinds of types that can be used with a generic component. Typescript allows us to do this using constraints.

For example, let‘s write a function that takes two arguments and returns the value of a shared property from both:

function getSharedProperty<T, U>(obj1: T, obj2: U, key: keyof (T & U)): (T & U)[keyof (T & U)] {
  return obj1[key] ?? obj2[key];
}

There‘s a lot going on here, so let‘s break it down:

  • The function has two type parameters, T and U, representing the types of the two object arguments
  • The key parameter has the type keyof (T & U), meaning it must be a property name shared between T and U
  • The return type (T & U)[keyof (T & U)] looks up the type of the shared property

By using the & operator, we constrain T and U to types that share some properties. And by using keyof, we make sure that the key argument is actually a shared property between them.

Here‘s how we can use this function:

const obj1 = { a: 1, b: 2 };
const obj2 = { b: 20, c: 30 };

const sharedValue = getSharedProperty(obj1, obj2, "b");
//    ^ = const sharedValue: number

const invalidCall = getSharedProperty(obj1, obj2, "a");
//    ^ Type ‘"a"‘ is not assignable to type ‘"b"‘.

The Typescript compiler infers that the shared property "b" has the type number based on the actual argument types. And it prevents us from trying to access a non-shared property with a helpful error message.

Conditional type constraints are another advanced feature of Typescript generics. They allow us to express conditional logic in type declarations that depends on one or more type parameters.

For example, Typescript provides a built-in conditional type called Exclude<T, U> that removes types from T that are assignable to U:

type MyType1 = Exclude<string | number, number>;
//   ^ = type MyType1 = string

type MyType2 = Exclude<"a" | "b" | "c", "a" | "b">;
//   ^ = type MyType2 = "c"

We can also write our own custom conditional types. Here‘s an example that creates a "flattened" version of an object type:

type Flatten<T> = T extends object ? { [K in keyof T]: T[K] } : T;

type MyObject = {
  a: {
    b: number;
    c: string;
  };
  d: boolean;
};

type FlatObject = Flatten<MyObject>;
//   ^ = type FlatObject = { 
//       a: {
//           b: number; 
//           c: string; 
//       }; 
//       d: boolean; 
//   }

The Flatten type uses the extends keyword to check if T is an object type. If so, it creates a new object type where each property of T is "flattened" to its own top-level property. Otherwise, it just returns T unchanged.

When to use generics

With all of these capabilities, you may be wondering when it makes sense to use generics in your own Typescript code. Here are some guidelines:

Use generics when you are writing reusable code components that need to work with a variety of types determined by the consumer of the code. This includes utility functions, common data structures, repository interfaces, and so on.

Consider generics when you find yourself writing multiple copies of the same code just to handle different types. Generics can help you follow the DRY principle.

Generics are useful for enforcing type relationships and constraints between related values. For example, you can ensure that a function returns the same type that it takes as a parameter.

On the flip side, don‘t use generics just for the sake of using them. If a component truly only needs to handle a single specific type, there‘s no need to over-engineer it with type parameters.

Also be aware of the tradeoffs between generics and other type system features like any, unions, intersections, and interfaces. Sometimes these alternatives are simpler while still providing the type-safety you need.

Conclusion

Generics are a powerful tool for writing reusable and type-safe code in Typescript. By letting you parameterize types, they provide a way to write components that can adapt to a variety of type contexts.

In this article, we covered the fundamentals of how generics work in Typescript. We looked at practical examples of generic functions, interfaces, and classes. And we explored some of the more advanced capabilities like type constraints and conditional types.

Generics can take some practice to master and aren‘t always the right solution. But having a solid grasp of how to use them is an important skill for any Typescript developer.

The next time you find yourself copy-pasting code just to handle a different type, try refactoring it with generics instead. Your future self will thank you for keeping the codebase clean and DRY!

Similar Posts