TypeScript Generics for Everyday Code: A Practical Guide

Frontend2026-09-21TryQuickToolBox

You're writing a function that works with any type, but you don't want to lose type safety. You could use any, but that defeats the purpose of TypeScript. Generics let you write code that works with multiple types while preserving the type information. This guide shows you how to use generics in everyday code without overcomplicating things.

What Are Generics?

Generics are a way to create reusable components that work with a variety of types rather than a single one. They allow you to capture the type of the input and use it in the output, ensuring type safety without sacrificing flexibility.

Think of generics as type parameters. Just like a function takes value parameters, a generic function takes type parameters. For example, the identity function:

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

Here, T is a type parameter. When you call identity<string>("hello"), TypeScript infers that T is string, and the return type is string. If you pass a number, it returns a number. This is more precise than using any.

Why Not Just Use any?

Using any disables type checking. You lose autocompletion, refactoring support, and the compiler can't catch mistakes. Generics keep the type information flowing through your code, so you get both flexibility and safety.

Generic Functions in Practice

Let's look at a common scenario: a function that returns the first element of an array.

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

Without generics, you might write function firstElement(arr: any[]): any, which loses the type. With generics, if you pass a string[], the return type is string | undefined.

You can also use multiple type parameters. For example, a function that maps an array to a new array using a callback:

function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
  return arr.map(fn);
}

This is essentially how Array.prototype.map is typed in TypeScript's standard library.

Generic Interfaces and Type Aliases

Generics are not limited to functions. You can create generic interfaces and type aliases to define reusable data structures.

interface Box<T> {
  value: T;
}

const stringBox: Box<string> = { value: "hello" };
const numberBox: Box<number> = { value: 42 };

This is useful for API responses, state management, or any container type.

You can also provide default types:

interface ApiResponse<T = unknown> {
  data: T;
  status: number;
}

Now ApiResponse without a type argument defaults to unknown.

Generic Constraints

Sometimes you want to restrict the types that can be used with a generic. You can use the extends keyword to add a constraint.

function getLength<T extends { length: number }>(arg: T): number {
  return arg.length;
}

Now T must have a length property. You can call getLength("hello") or getLength([1,2,3]), but not getLength(42).

Constraints are powerful when working with objects that share a common shape.

Using Generics with Classes

Classes can also be generic. A common example is a generic collection:

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

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

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

const stack = new Stack<number>();
stack.push(1);
stack.push(2);
const popped = stack.pop(); // number | undefined

This ensures that only numbers can be pushed onto a number stack.

Real-World Example: A Type-Safe Event Emitter

Let's build a simple event emitter that uses generics to map event names to payload types.

type EventMap = Record<string, any>;

class EventEmitter<T extends EventMap> {
  private listeners: { [K in keyof T]?: Array<(payload: T[K]) => void> } = {};

  on<K extends keyof T>(event: K, listener: (payload: T[K]) => void): void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(listener);
  }

  emit<K extends keyof T>(event: K, payload: T[K]): void {
    this.listeners[event]?.forEach(listener => listener(payload));
  }
}

// Usage
interface MyEvents {
  login: { userId: string };
  logout: { userId: string };
}

const emitter = new EventEmitter<MyEvents>();
emitter.on('login', (payload) => {
  console.log(payload.userId); // string
});
emitter.emit('login', { userId: '123' }); // OK
// emitter.emit('login', { userId: 123 }); // Error

This pattern gives you full type safety for event names and payloads, reducing runtime errors.

Common Pitfalls and Best Practices

Comparison: Generics vs. any vs. Union Types

Approach Type Safety Flexibility Use Case
Generics High High Reusable components with preserved types
any None High Quick prototyping, migrating JS
Union Types Medium Medium Limited set of known types

Generics strike a balance between flexibility and safety, making them ideal for libraries and shared utilities.

FAQ

When should I use generics instead of union types?

Use generics when the type relationship between inputs and outputs matters. Union types are for when a value can be one of a fixed set of types, but the specific type doesn't need to be preserved across operations.

Can I use generics with arrow functions?

Yes, but syntax can be tricky in .tsx files. You can write const identity = <T,>(arg: T): T => arg; with a trailing comma to avoid JSX ambiguity.

How do I constrain a generic to only accept objects?

Use T extends object or T extends Record<string, unknown> to ensure T is an object type.

Generics are a cornerstone of TypeScript's type system. By using them in your everyday code, you can write functions, interfaces, and classes that are both flexible and safe. Start with simple generic functions, then gradually incorporate constraints and more complex patterns as needed. For more developer tools, check out our JSON Formatter to validate and format your JSON data quickly.