TypeScript Generics: A Practical Guide

Have you ever found yourself writing duplicate code to handle different data types? Or worse, resorting to using any and losing all the benefits of TypeScript's type checking? If so, generics are about to become your new best friend.

In this tutorial, I'll show you how to use generics in TypeScript to write reusable, type-safe code. I'll cover everything from basic usage in functions to more complex scenarios with interfaces and classes.

What are Generics?

Generics are essentially parameterized types - they allow you to provide the type to a function, interface, or class as a parameter. This means you can write a single implementation that works with a variety of types while maintaining full type safety.

Without generics, you'd have two options:

  1. Use specific types and duplicate code for each type
  2. Use any and lose type safety

Neither solution is ideal. Let's see how generics solve this problem.

The Problem with 'any'

Before we dive into generics, let's understand why using any is problematic. Consider this interface for comparing objects:

interface Comparator {
  compare(value: any): number;
}

class Rectangle implements Comparator {
  width: number;
  height: number;
  
  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
  }
  
  compare(value: any): number {
    // Compare based on area
    const thisArea = this.width * this.height;
    const valueArea = value.width * value.height;
    return thisArea - valueArea;
  }
}

class Triangle implements Comparator {
  base: number;
  height: number;
  
  constructor(base: number, height: number) {
    this.base = base;
    this.height = height;
  }
  
  compare(value: any): number {
    // Compare based on area
    const thisArea = (this.base * this.height) / 2;
    const valueArea = (value.base * value.height) / 2;
    return thisArea - valueArea;
  }
}

At first glance, this might seem fine. But what happens here?

const rectangle = new Rectangle(10, 5);
const triangle = new Triangle(10, 5);

// TypeScript won't complain, but this will cause a runtime error
rectangle.compare(triangle); // Oops! triangle doesn't have width/height properties

Since value is typed as any, TypeScript won't warn you about this error, and it'll blow up at runtime. Not good!

Generics to the Rescue

Here's how we can improve the example using generics:

interface Comparator<T> {
  compare(value: T): number;
}

class Rectangle implements Comparator<Rectangle> {
  width: number;
  height: number;
  
  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
  }
  
  compare(value: Rectangle): number {
    // Compare based on area
    const thisArea = this.width * this.height;
    const valueArea = value.width * value.height;
    return thisArea - valueArea;
  }
}

class Triangle implements Comparator<Triangle> {
  base: number;
  height: number;
  
  constructor(base: number, height: number) {
    this.base = base;
    this.height = height;
  }
  
  compare(value: Triangle): number {
    // Compare based on area
    const thisArea = (this.base * this.height) / 2;
    const valueArea = (value.base * value.height) / 2;
    return thisArea - valueArea;
  }
}

Now, if we try to compare a rectangle to a triangle:

const rectangle = new Rectangle(10, 5);
const triangle = new Triangle(10, 5);

// TypeScript will catch this error at compile time
rectangle.compare(triangle); // Error: Argument of type 'Triangle' is not assignable to parameter of type 'Rectangle'

TypeScript catches the error before it becomes a runtime issue. This is the power of generics - we get both code reuse and type safety.

Generic Functions

Let's look at how to use generics with functions:

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

// TypeScript infers the return type automatically
const num = identity(42);        // num is number
const str = identity("hello");   // str is string
const arr = identity([1, 2, 3]); // arr is number[]

// You can also explicitly specify the type
const explicitNum = identity<number>(42);

The syntax looks a bit strange at first, but it's quite straightforward:

  1. <T> declares a generic type parameter for the function
  2. (value: T) specifies that the parameter should be of that type
  3. : T indicates that the function returns that same type

You can also use generics with arrow functions:

const identityArrow = <T>(value: T): T => {
  return value;
};

Default Type Parameters

When you define a generic class, interface, or function, you sometimes want to provide a default type that will be used if the caller doesn't specify one:

class Box<T = string> {
  value: T;
  
  constructor(value: T) {
    this.value = value;
  }
}

// Uses the default type (string)
const stringBox = new Box("hello");

// Overrides the default type
const numberBox = new Box<number>(42);

This is especially useful when extending generic classes:

class A<T> {
  value: T;
  
  constructor(value: T) {
    this.value = value;
  }
}

// Error: Generic type 'A<T>' requires 1 type argument(s)
class B extends A {
  // ...
}

// Fixed with a default type
class A2<T = any> {
  value: T;
  
  constructor(value: T) {
    this.value = value;
  }
}

// Works fine now
class B2 extends A2 {
  // ...
}

// Or you can explicitly provide a type
class C extends A<number> {
  // ...
}

Multiple Type Parameters

You can use multiple type parameters when you need different types in your generic:

class Pair<K, V> {
  constructor(public key: K, public value: V) {}
}

function compare<K, V>(p1: Pair<K, V>, p2: Pair<K, V>): boolean {
  return p1.key === p2.key && p1.value === p2.value;
}

const a = new Pair("name", 10);
const b = new Pair("name", 20);
const c = new Pair(1, "value");
const d = new Pair(1, "value");

console.log(compare(a, b)); // false
console.log(compare(c, d)); // true

Constraining Types with Extends

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

interface HasLength {
  length: number;
}

// T must have a length property
function logLength<T extends HasLength>(value: T): T {
  console.log(value.length);
  return value;
}

// These work
logLength("hello");       // String has a length property
logLength([1, 2, 3]);     // Array has a length property
logLength({ length: 5 }); // Object has a length property

// This doesn't work
logLength(42); // Error: Argument of type 'number' is not assignable to parameter of type 'HasLength'

Using Built-in Generics

TypeScript has many built-in generics that you probably use already:

Arrays

const numbers: Array<number> = [1, 2, 3];
const strings: Array<string> = ["a", "b", "c"];

Promises

const promise: Promise<string> = new Promise((resolve) => {
  setTimeout(() => resolve("done"), 1000);
});

promise.then((value) => {
  console.log(value.toUpperCase()); // TypeScript knows value is a string
});

Maps and Sets

const map: Map<string, number> = new Map();
map.set("answer", 42);

const set: Set<number> = new Set([1, 2, 3]);

Real-World Example: Building a Type-Safe API Client

Let's put this all together with a more practical example - a generic API client:

interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}

class ApiClient {
  baseUrl: string;
  
  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
  }
  
  async get<T>(endpoint: string): Promise<ApiResponse<T>> {
    const response = await fetch(`${this.baseUrl}${endpoint}`);
    const json = await response.json();
    
    return {
      data: json as T,
      status: response.status,
      message: response.statusText
    };
  }
  
  async post<T, U>(endpoint: string, body: T): Promise<ApiResponse<U>> {
    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(body)
    });
    
    const json = await response.json();
    
    return {
      data: json as U,
      status: response.status,
      message: response.statusText
    };
  }
}

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

interface NewUser {
  name: string;
  email: string;
}

const api = new ApiClient('https://api.example.com');

// TypeScript knows this returns Promise<ApiResponse<User[]>>
const getUsers = () => api.get<User[]>('/users');

// TypeScript knows this returns Promise<ApiResponse<User>>
const createUser = (user: NewUser) => api.post<NewUser, User>('/users', user);

// Now we can use it with full type safety
async function main() {
  const usersResponse = await getUsers();
  const users = usersResponse.data;
  
  // TypeScript knows users is User[]
  const firstUser = users[0];
  console.log(firstUser.name);
  
  const newUserResponse = await createUser({ name: "John", email: "john@example.com" });
  const newUser = newUserResponse.data;
  
  // TypeScript knows newUser is User
  console.log(newUser.id);
}

With this API client, we get full type safety for our API calls. The payload and response types are checked at compile time, so we can't accidentally send the wrong data or expect the wrong response format.

Tips for Using Generics Effectively

  1. Start simple: Don't over-complicate things. If you only need one generic parameter, stick with one.
  2. Use descriptive type parameter names: While T is fine for simple cases, use more descriptive names for complex cases (Key, Value, Element, etc.).
  3. Consider using default types: They can make your code more convenient to use.
  4. Use constraints when possible: They provide better type checking and better IntelliSense.
  5. Avoid using any as a constraint: It defeats the purpose of using generics in the first place.

Wrapping Up

Generics in TypeScript are a powerful tool for creating reusable, type-safe code. They might seem intimidating at first, but once you grasp the basics, they become an essential part of your TypeScript toolkit.

In this tutorial, we've covered:

  • Basic generic syntax
  • Generic interfaces and classes
  • Functions with generic parameters
  • Default type parameters
  • Multiple type parameters
  • Type constraints
  • Built-in generics
  • A real-world example of generics in action

I hope this gives you a solid foundation for using generics in your projects. If you spot any errors or have questions, please let me know in the comments below!