Back to articles

Understanding TypeScript Generics: A Practical Guide

April 10, 2024 (1y ago)

·
3 min read

TypeScript generics are one of the most powerful features of the language, allowing you to write flexible, reusable code while maintaining type safety. Let's dive deep into how they work and when to use them.

What Are Generics?

Generics allow you to create components that work with multiple types rather than a single one. Think of them as "type variables" that get filled in when you use the component.

Basic Generic Syntax

Here's a simple generic function:

function identity<T>(arg: T): T {
  return arg;
}
 
// Usage
const str = identity<string>("hello"); // type: string
const num = identity<number>(42);      // type: number
const auto = identity("world");        // type: string (inferred)

Generic Interfaces

Create flexible interfaces with generics:

interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
  timestamp: Date;
}
 
interface User {
  id: number;
  name: string;
  email: string;
}
 
// Usage
const userResponse: ApiResponse<User> = {
  data: { id: 1, name: "John", email: "john@example.com" },
  status: 200,
  message: "Success",
  timestamp: new Date(),
};

Generic Constraints

Use extends to limit what types can be used:

interface HasLength {
  length: number;
}
 
function logLength<T extends HasLength>(arg: T): number {
  console.log(arg.length);
  return arg.length;
}
 
logLength("hello");      // ✓ Works - strings have length
logLength([1, 2, 3]);    // ✓ Works - arrays have length
logLength({ length: 5 }); // ✓ Works - object with length property
// logLength(42);        // ✗ Error - numbers don't have length

Multiple Type Parameters

Use multiple generics for more complex scenarios:

function pair<K, V>(key: K, value: V): [K, V] {
  return [key, value];
}
 
const p1 = pair("name", "John");    // [string, string]
const p2 = pair(1, true);           // [number, boolean]

Generic Classes

Create reusable data structures:

class Queue<T> {
  private items: T[] = [];
 
  enqueue(item: T): void {
    this.items.push(item);
  }
 
  dequeue(): T | undefined {
    return this.items.shift();
  }
 
  peek(): T | undefined {
    return this.items[0];
  }
 
  get size(): number {
    return this.items.length;
  }
}
 
// Usage
const numberQueue = new Queue<number>();
numberQueue.enqueue(1);
numberQueue.enqueue(2);
 
const stringQueue = new Queue<string>();
stringQueue.enqueue("hello");

Real-World Example: Fetch Wrapper

Here's a practical example of a type-safe fetch wrapper:

async function fetchData<T>(url: string): Promise<ApiResponse<T>> {
  const response = await fetch(url);
  const data = await response.json();
  
  return {
    data: data as T,
    status: response.status,
    message: response.ok ? "Success" : "Error",
    timestamp: new Date(),
  };
}
 
// Usage
interface Post {
  id: number;
  title: string;
  body: string;
}
 
const posts = await fetchData<Post[]>("/api/posts");
// posts.data is typed as Post[]

Generic Utility Types

TypeScript provides built-in generic utility types:

// Partial - makes all properties optional
type PartialUser = Partial<User>;
 
// Required - makes all properties required
type RequiredUser = Required<User>;
 
// Pick - select specific properties
type UserName = Pick<User, "name">;
 
// Omit - exclude specific properties
type UserWithoutId = Omit<User, "id">;
 
// Record - create a type with specific keys and value type
type UserRoles = Record<string, User>;

Conclusion

Generics are essential for writing scalable TypeScript code. Start with simple use cases and gradually incorporate more complex patterns as you become comfortable. The key is to think about reusability while maintaining type safety.

Practice these patterns in your projects, and you'll find yourself writing more robust and maintainable code!