How TypeScript Generics Help You Write Less Code

TypeScript is a powerful language that enables writing safer, more maintainable JavaScript code. One of its key features is generics, which allow creating reusable components that work with a variety of types rather than a single one. When used effectively, generics can significantly reduce code duplication and make your TypeScript cleaner and more expressive.

In this post, we‘ll take an in-depth look at how generics work and explore several examples of how they can help you write less code. Whether you‘re a TypeScript beginner or have some experience with the language, I hope you‘ll come away with a better understanding of this important feature and how to leverage it in your own projects.

What are TypeScript Generics?

In simple terms, generics allow you to write code that can work with multiple types rather than being tied to a specific one. They enable creating reusable functions, classes, and interfaces that can adapt to the types provided when they are called.

For example, consider a simple identity function that takes an argument and returns it unchanged:

function identity(arg: number): number {
  return arg;
}

This identity function only works with numbers. If we wanted similar behavior for strings, we‘d have to duplicate the code:

function identity(arg: string): string {
  return arg;
}

With generics, we can write this function once and have it work with any type:

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

The T in angle brackets is a type variable that captures the type provided by the caller. Now we can use the same identity function with numbers, strings, or any other type:

let myNumber = identity(10);
let myString = identity("Hello world");

This is just a simple example, but it demonstrates the core idea behind generics: allowing code to be more flexible and reusable. Let‘s look at some more examples to see the full power of generics.

Interfaces and Generics

Interfaces are a key part of modeling types in TypeScript, and generics can make them significantly more expressive. Consider a simple interface for a repository that can get and save entities by ID:

interface Repository {
  get(id: string): Object;
  save(entity: Object): void;
}

This Repository interface is limited since it only works with the base Object type. We could make it more specific by using a type like User:

interface UserRepository {
  get(id: string): User;
  save(entity: User): void;  
}

But then if we wanted a Repository for a different entity like Product, we‘d have to duplicate the interface. Generics provide a better way:

interface Repository<T> {
  get(id: string): T;
  save(entity: T): void;
}

Now the type T is a placeholder for the actual entity type, which can be provided by the user of the interface:

class UserRepository implements Repository<User> {
  get(id: string): User {
    // ...
  }

  save(user: User): void {
    // ...
  } 
}

We‘ve written the Repository interface once, but it can be used with any type. This makes the code more reusable and expressive – a UserRepository can only work with User objects, and attempting to use it with another type will result in a compile error.

Functions and Generics

We saw a simple generic identity function earlier, but generics can enable more substantial code reuse in functions. A common use case is collections – consider functions to filter an array or get its first element:

function filter(array: number[], predicate: (item: number) => boolean): number[] {
  // ...
}

function head(array: string[]): string | undefined {
  return array[0];
}

Again, these are limited to specific types (number and string). We can use generics to make them work with any array:

function filter<T>(array: T[], predicate: (item: T) => boolean): T[] {
  // ...
}

function head<T>(array: T[]): T | undefined {
  return array[0];
}

Now filter and head can be used with arrays of any type:

let numbers = [1, 2, 3, 4, 5];
let evenNumbers = filter(numbers, (n) => n % 2 === 0);
//    ^? number[]

let strings = ["a", "b", "c"];
let firstString = head(strings);
//    ^? string | undefined

The type inference system is able to correctly infer the types based on how the function is called. If we passed an array of a different type, or a predicate that expected a different type, we‘d get a compile error.

This kind of generic code reuse is extremely powerful, especially for utility functions that operate on collections. Libraries like Lodash make heavy use of generics to provide a type-safe, reusable set of collection utilities.

Classes and Generics

Classes can also benefit from generics. Consider a simple class that represents a pair of values:

class Pair {
  constructor(public first: string, public second: string) {}
}

This Pair class is limited to string values. We could make it more generic like this:

class Pair<T, U> {
  constructor(public first: T, public second: U) {}
}

Now Pair can be used with any two types:

let stringPair = new Pair("hello", "world");
//    ^? Pair<string, string>

let mixedPair = new Pair(10, "ten");
//    ^? Pair<number, string>

This is a simple example, but it demonstrates how generics can enable more reusable class designs. Libraries like Immutable.js use generics heavily to provide type-safe, immutable data structures.

Advanced Generics Concepts

The examples we‘ve seen so far demonstrate the basics of generics, but there are a few more advanced concepts worth knowing about.

Generic Constraints

Sometimes you want to constrain the types that can be used with a generic. You can do this with the extends keyword:

function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length > b.length ? a : b;
}

This longest function can only be used with types that have a length property of type number. So it would work with arrays and strings, but not with numbers or booleans.

Default Types

You can also provide default types for generics:

class Collection<T = string> {
  constructor(public items: T[]) {}
}

let strings = new Collection(["a", "b", "c"]);
//    ^? Collection<string>

let numbers = new Collection([1, 2, 3]);
//    ^? Collection<number>

If no type is provided, T defaults to string. This can be useful for providing sensible defaults while still allowing customization.

A Real-World Example

Let‘s walk through a more substantial example of refactoring some code to use generics. Consider a simple blogging application that has two types of entities, User and Post:

interface User {
  id: number;
  name: string;
  email: string;
}

interface Post {
  id: number;
  title: string;
  body: string;
  authorId: number;
}

Our application needs functionality to fetch these entities from an API. We could write separate functions for each type:

async function fetchUser(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  return await response.json();
}

async function fetchPost(id: number): Promise<Post> {
  const response = await fetch(`/api/posts/${id}`);
  return await response.json();
}

There‘s a lot of duplication here. The only differences are the type of entity being fetched and the API endpoint. We can refactor this to use generics:

async function fetchEntity<T>(type: string, id: number): Promise<T> {
  const response = await fetch(`/api/${type}/${id}`);
  return await response.json();
}

const user = await fetchEntity<User>(‘users‘, 1);
const post = await fetchEntity<Post>(‘posts‘, 1);

Now we have a single fetchEntity function that can fetch any type of entity. The type parameter specifies the API endpoint to use, and the generic type T specifies the type of entity to return.

We can take this a step further and create a generic API class:

class API {
  constructor(private baseUrl: string) {}

  async fetchEntity<T>(type: string, id: number): Promise<T> {
    const response = await fetch(`${this.baseUrl}/${type}/${id}`);
    return await response.json();
  }

  async createEntity<T>(type: string, entity: T): Promise<T> {
    const response = await fetch(`${this.baseUrl}/${type}`, {
      method: ‘POST‘,
      headers: { ‘Content-Type‘: ‘application/json‘ },
      body: JSON.stringify(entity),
    });
    return await response.json();
  }

  // Other CRUD methods...
}

Now all of our API functionality is encapsulated in a single class, and it all benefits from the power of generics. We can use this API class with any of our entity types:

const api = new API(‘/api‘);

const newUser: User = await api.createEntity(‘users‘, {
  name: ‘Alice‘,
  email: ‘[email protected]‘,
});

const existingPost: Post = await api.fetchEntity(‘posts‘, 1);

This is a significant improvement over our original version. We‘ve eliminated a lot of duplication, and our code is more flexible and reusable. If we add a new entity type, we don‘t have to write new API functions for it – we can just use the existing generic methods.

Limitations and Best Practices

While generics are a powerful feature, it‘s important to use them judiciously. Overusing generics can make your code harder to read and understand. If a function or class only needs to work with a single type, it‘s often better to be explicit about that type rather than making it generic.

Generics also have some limitations. They are erased at runtime, which means you can‘t use them for runtime type checks or reflection. And they can sometimes lead to complex type definitions that are hard to read and maintain.

As a general rule, use generics when you need to write code that works with multiple types in a type-safe way. But if your code only needs to work with a single type, or if using generics would make your code more complex and harder to understand, it‘s often better to stick with regular types.

Conclusion

TypeScript generics are a powerful feature that can help you write more reusable, flexible, and type-safe code. By allowing you to write code that works with multiple types, generics can significantly reduce duplication and make your code more expressive.

In this post, we‘ve seen how to use generics with interfaces, functions, and classes, and explored some real-world examples of how they can be used to refactor and improve TypeScript code. We‘ve also discussed some of the limitations and best practices for using generics.

If you‘re new to TypeScript, I hope this post has given you a good introduction to generics and how they can be used to write better code. And if you‘re already familiar with TypeScript, I hope you‘ve found some new insights and ideas for leveraging generics in your own projects.

Happy coding!

Similar Posts