Understanding TypeScript Generics

2024-02-15
Admin
TypeScript
#typescript#programming

A comprehensive deep dive into TypeScript generics. Learn how to write flexible, reusable, and type-safe code using generic types, constraints, utility types, and real-world patterns.

Understanding TypeScript Generics

Generics are one of the most powerful features in TypeScript, yet they often intimidate developers who are new to strongly typed languages. In this deep dive, we will demystify generics, explore their syntax, and work through practical examples that demonstrate why they are indispensable for writing robust, reusable code.

What Are Generics?

At their core, generics allow you to write code that works with multiple types while still maintaining full type safety. Think of them as type variables -- placeholders for types that are specified later when the code is actually used.

Without generics, you would face an unpleasant choice:

// Option 1: Specific but not reusable
function getFirstNumber(arr: number[]): number | undefined {
  return arr[0];
}

function getFirstString(arr: string[]): string | undefined {
  return arr[0];
}

// Option 2: Reusable but not type-safe
function getFirstAny(arr: any[]): any {
  return arr[0];
}

With generics, you get the best of both worlds:

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

const num = getFirst([1, 2, 3]);        // type: number | undefined
const str = getFirst(["a", "b", "c"]);  // type: string | undefined
const obj = getFirst([{ id: 1 }]);      // type: { id: number } | undefined

The <T> syntax declares a type parameter named T. When you call getFirst([1, 2, 3]), TypeScript infers that T is number and enforces that throughout the function.

Generic Functions

Let us look at several common patterns for generic functions.

Identity Function

The simplest generic function is the identity function, which returns exactly what it receives:

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

const result = identity("hello"); // type: string

Multiple Type Parameters

Functions can accept multiple type parameters:

function pair<A, B>(first: A, second: B): [A, B] {
  return [first, second];
}

const p = pair("hello", 42); // type: [string, number]

Generic Arrow Functions

Arrow functions can also be generic. Note the syntax difference in TSX files:

// Standard TypeScript file
const wrap = <T>(value: T): { data: T } => ({ data: value });

// In TSX files, add a trailing comma to disambiguate from JSX
const wrapTsx = <T,>(value: T): { data: T } => ({ data: value });

Generic Interfaces and Types

Generics are not limited to functions. They work beautifully with interfaces and type aliases.

Generic Interfaces

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

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

interface Product {
  id: number;
  name: string;
  price: number;
}

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

const productResponse: ApiResponse<Product> = {
  data: { id: 1, name: "Widget", price: 29.99 },
  status: 200,
  message: "Success",
  timestamp: new Date(),
};

Generic Type Aliases

type Result<T, E = Error> =
  | { success: true; data: T }
  | { success: false; error: E };

function divide(a: number, b: number): Result<number, string> {
  if (b === 0) {
    return { success: false, error: "Division by zero" };
  }
  return { success: true, data: a / b };
}

const result = divide(10, 2);
if (result.success) {
  console.log(result.data); // TypeScript knows data is number here
} else {
  console.log(result.error); // TypeScript knows error is string here
}

Notice the E = Error syntax -- this is a default type parameter, meaning if you do not specify the error type, it defaults to Error.

Generic Constraints

Sometimes you need to restrict which types a generic can accept. This is where constraints come in, using the extends keyword.

Basic Constraints

interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(value: T): T {
  console.log(`Length: ${value.length}`);
  return value;
}

logLength("hello");           // OK: strings have .length
logLength([1, 2, 3]);         // OK: arrays have .length
logLength({ length: 10 });    // OK: object has .length property

// logLength(123);            // Error: number doesn't have .length

The keyof Constraint

One of the most useful patterns is constraining a type parameter to be a key of another type:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: "Alice", age: 30, email: "alice@example.com" };

const name = getProperty(user, "name");   // type: string
const age = getProperty(user, "age");     // type: number

// getProperty(user, "phone");            // Error: "phone" is not a key of user

This pattern ensures that you can only access properties that actually exist on the object, and the return type is correctly inferred.

Constraining to Object Types

function merge<T extends object, U extends object>(a: T, b: U): T & U {
  return { ...a, ...b };
}

const merged = merge({ name: "Alice" }, { age: 30 });
// type: { name: string } & { age: number }

// merge("hello", "world");  // Error: string is not an object

Generic Classes

Classes can also be generic, which is particularly useful for data structures:

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

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

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

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

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

  isEmpty(): boolean {
    return this.items.length === 0;
  }
}

const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
numberStack.push(3);

const top = numberStack.pop(); // type: number | undefined

const stringStack = new Stack<string>();
stringStack.push("hello");
// stringStack.push(42);      // Error: number is not assignable to string

Advanced Patterns

Let us explore some more advanced generic patterns that you will encounter in real-world TypeScript codebases.

Conditional Types

Conditional types let you express type logic:

type IsString<T> = T extends string ? true : false;

type A = IsString<string>;  // true
type B = IsString<number>;  // false

// Practical example: extract the return type of a promise
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

type X = UnwrapPromise<Promise<string>>;  // string
type Y = UnwrapPromise<number>;           // number

The infer Keyword

The infer keyword lets you extract types from within other types:

type GetArrayElement<T> = T extends (infer U)[] ? U : never;

type Elem = GetArrayElement<string[]>;    // string
type Elem2 = GetArrayElement<number[]>;   // number

// Extract function parameter types
type FirstParam<T> = T extends (first: infer P, ...args: any[]) => any
  ? P
  : never;

type P = FirstParam<(name: string, age: number) => void>; // string

Mapped Types with Generics

Generics combine powerfully with mapped types:

type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};

type Optional<T> = {
  [K in keyof T]?: T[K];
};

type Nullable<T> = {
  [K in keyof T]: T[K] | null;
};

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

type ReadonlyUser = Readonly<User>;
// { readonly id: number; readonly name: string; readonly email: string }

type OptionalUser = Optional<User>;
// { id?: number; name?: string; email?: string }

type NullableUser = Nullable<User>;
// { id: number | null; name: string | null; email: string | null }

Generic Factory Pattern

A common pattern in React applications is a generic factory for creating typed hooks or components:

interface CrudOperations<T> {
  getAll: () => Promise<T[]>;
  getById: (id: string) => Promise<T>;
  create: (data: Omit<T, "id">) => Promise<T>;
  update: (id: string, data: Partial<T>) => Promise<T>;
  delete: (id: string) => Promise<void>;
}

function createCrudService<T extends { id: string }>(
  baseUrl: string
): CrudOperations<T> {
  return {
    getAll: async () => {
      const res = await fetch(baseUrl);
      return res.json();
    },
    getById: async (id) => {
      const res = await fetch(`${baseUrl}/${id}`);
      return res.json();
    },
    create: async (data) => {
      const res = await fetch(baseUrl, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data),
      });
      return res.json();
    },
    update: async (id, data) => {
      const res = await fetch(`${baseUrl}/${id}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data),
      });
      return res.json();
    },
    delete: async (id) => {
      await fetch(`${baseUrl}/${id}`, { method: "DELETE" });
    },
  };
}

// Usage
interface Post {
  id: string;
  title: string;
  content: string;
}

const postService = createCrudService<Post>("/api/posts");
// postService.getAll() returns Promise<Post[]>
// postService.getById("1") returns Promise<Post>
// postService.create({ title: "Hi", content: "..." }) returns Promise<Post>

Common Pitfalls and Tips

Here are some things to keep in mind when working with generics:

  1. Do not over-generalize. If a function only ever works with one type, you do not need generics. Use them when you genuinely need flexibility.

  2. Name your type parameters meaningfully. While T is conventional for a single parameter, use descriptive names for complex scenarios:

    // Less clear
    function transform<T, U>(input: T, fn: (item: T) => U): U;
    
    // More clear
    function transform<Input, Output>(
      input: Input,
      fn: (item: Input) => Output
    ): Output;
    
  3. Leverage type inference. You usually do not need to explicitly specify type parameters -- TypeScript is excellent at inferring them:

    // Unnecessary -- TypeScript infers <number>
    const result = getFirst<number>([1, 2, 3]);
    
    // Better -- let TypeScript infer it
    const result = getFirst([1, 2, 3]);
    
  4. Use constraints wisely. Only add constraints when you actually need to access specific properties or methods of the type parameter.

Conclusion

Generics are the cornerstone of writing flexible, reusable, and type-safe TypeScript code. They may seem daunting at first, but once you internalize the core concepts -- type parameters, constraints, and inference -- they become second nature.

Here is a quick reference of what we covered:

  • Basic generics -- Type parameters with <T> syntax
  • Constraints -- Limiting types with extends
  • keyof patterns -- Type-safe property access
  • Generic classes -- Type-safe data structures
  • Conditional types -- Type-level logic with extends and infer
  • Mapped types -- Transforming existing types into new ones
  • Factory patterns -- Creating reusable, typed abstractions

Start small by applying generics to your utility functions and data-fetching layers. As you gain confidence, you will find yourself reaching for more advanced patterns naturally.


Have questions about generics or TypeScript in general? Drop a comment below -- we would love to hear from you!