jsguides

TypeScript Generics: Type-Safe Reusable Code

TypeScript generics are one of the language’s most powerful features. They let you write flexible, reusable code while maintaining full type safety. Instead of locking your functions or classes to a single type, generics allow you to parameterize types, just like functions parameterize values. This tutorial walks through everything from basic type parameters to constraints, multiple type variables, and real-world patterns like generic repositories.

Before you start

You should understand basic TypeScript types and interfaces. If you’re new to TypeScript, work through Getting Started with TypeScript first. This tutorial covers generic functions, interfaces, constraints, classes, multiple type parameters, and practical repository patterns.

Why generics matter

Imagine you need a function that returns whatever you pass into it:

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

This works, but you’ve lost type information. When you call identity("hello"), TypeScript has no idea it returns a string. The return type is any, which means the compiler can’t catch mistakes when you try to call string methods on what might actually be a number. Generics solve this by capturing the concrete type at the call site:

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

const str = identity("hello");  // type: string
const num = identity(42);         // type: number

The <T> is a type parameter — a placeholder that gets filled in when you call the function. TypeScript infers T from the argument, so you get full type safety without extra code.

Generic Functions

Generic functions use one or more type parameters:

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

const nums = [1, 2, 3];
const firstNum = firstElement(nums);  // type: number

const words = ["hello", "world"];
const firstWord = firstElement(words);  // type: string

You can also explicitly specify type arguments when inference isn’t possible — for example, when the function doesn’t receive an argument of the type you want to capture. Explicit type arguments make the intent clear at the call site:

const explicit = firstElement<string>(["hello"]);  // type: string

Generic interfaces and types

Generics work with interfaces too, which is useful when you need to wrap API responses, paginated results, or any container type around a payload whose shape varies. The interface defines the structure while the type parameter determines the payload:

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

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

const userResponse: ApiResponse<User> = {
  data: { id: 1, name: "Alice" },
  status: 200,
  message: "Success"
};

The ApiResponse<T> interface adapts to whatever data type you need, whether that’s a User, an array, or anything else. This pattern is common in HTTP clients where every endpoint returns the same envelope with a different data shape. Next, let’s look at how to restrict which types a generic can accept.

Generic Constraints

Sometimes you need to restrict what types can be used. Use extends to add constraints:

interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(item: T): void {
  console.log(item.length);
}

logLength("hello");      // OK - strings have length
logLength([1, 2, 3]);    // OK - arrays have length
logLength({ length: 5 }); // OK - object has length property
// logLength(42);        // Error - numbers don't have length

You can also constrain one generic type parameter based on another. The keyof operator produces a union of all property names, and combining it with extends guarantees that the second type argument is always a valid key of the first. This pattern is especially useful for type-safe property accessors:

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

const user = { name: "Bob", age: 30 };
const name = getProperty(user, "name");  // type: string
const age = getProperty(user, "age");    // type: number
// getProperty(user, "email");           // Error - 'email' not in user

Generic Classes

Classes can be generic too. A generic class wraps a value of any type and provides typed methods to access it. The type argument flows from the constructor call site through to every method return type, making the class fully type-safe:

class Box<T> {
  contents: T;

  constructor(contents: T) {
    this.contents = contents;
  }

  get(): T {
    return this.contents;
  }
}

const stringBox = new Box("books");
const numBox = new Box(123);

You can even constrain generic class types. A constraint on a class type parameter means every method can safely access the constrained properties without runtime checks or type assertions. This is useful when the class logic depends on specific properties, like a name field that every instance must provide:

class NamedBox<T extends { name: string }> {
  item: T;

  constructor(item: T) {
    this.item = item;
  }

  introduce(): string {
    return `This is ${this.item.name}`;
  }
}

const box = new NamedBox({ name: "Widget", price: 10 });
console.log(box.introduce());  // This is Widget

Multiple type parameters

Functions can accept multiple type parameters, which is essential when you need to track the relationship between two independent types. A common pattern is a key-value pair function that preserves both the key type and the value type in the return value:

function pair<K, V>(key: K, value: V): [K, V] {
  return [key, value];
}

const idName = pair(1, "Alice");      // [number, string]
const boolStr = pair(true, "enabled"); // [boolean, string]

The pair function returns a tuple with both types preserved. For more persistent storage, you can wrap the same two-type-parameter pattern in a generic factory that returns a Map. This is useful for caches, lookup tables, and registries where key and value types are independent:

function createCache<K, V>(): Map<K, V> {
  return new Map();
}

const userCache = createCache<string, User>();
userCache.set("alice", { id: 1, name: "Alice" });

Default type parameters

You can provide default types for generics:

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

const simpleResponse: ApiResponse = {
  data: "ok",
  status: 200
};

Here, T defaults to unknown when not specified, so callers that don’t care about the payload type can omit the type argument entirely. Defaults are most useful in library code where you want to support both typed and untyped consumers without forcing a choice.

Practical example: generic repository

Here’s how generics shine in real-world code. A repository pattern defines standard data operations (find, save, delete) once as a generic interface, then each entity type gets a concrete implementation. The interface guarantees consistent method signatures across every entity:

interface Repository<T> {
  findById(id: string): Promise<T | null>;
  findAll(): Promise<T[]>;
  save(item: T): Promise<T>;
  delete(id: string): Promise<void>;
}

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

class UserRepository implements Repository<User> {
  private users: Map<string, User> = new Map();

  async findById(id: string): Promise<User | null> {
    return this.users.get(id) || null;
  }

  async findAll(): Promise<User[]> {
    return Array.from(this.users.values());
  }

  async save(user: User): Promise<User> {
    this.users.set(user.id, user);
    return user;
  }

  async delete(id: string): Promise<void> {
    this.users.delete(id);
  }
}

The Repository<T> interface works for any entity: User, Product, Order. It maintains full type safety throughout.

Summary

Thinking about generic design

A good generic API starts with the shape of the data, not the syntax. Before adding a type parameter, ask what stays the same and what changes between calls. If the behavior is the same but the value type varies, a generic is usually a good fit. If each type needs its own custom logic, separate functions may be clearer. This is why many TypeScript utilities feel small but powerful: they capture one pattern and let the caller supply the varying piece.

It also helps to keep type parameters named after their role. T is fine for a single value type, but more descriptive names such as TItem, TResult, or TKey can make a large API easier to read. That matters in code review, where the type signature often tells the story before the implementation does. A clear name can also reduce the need for extra comments. When the names are meaningful, the constraint and return type become easier to scan in one pass.

Working With Inference

Type inference is one of the nicest parts of generics because it keeps call sites short. The compiler can usually infer the type argument from the inputs, so you only need to spell out the type when inference would be vague. That means the public API stays pleasant to use while still preserving exact types. In practice, the best generic functions are the ones people call without thinking about the type parameter at all.

When inference gets confused, the fix is usually to make the inputs more specific rather than adding more annotations at the call site. For example, if a function accepts multiple arrays or mixed object shapes, a small helper type or a narrower parameter type can guide the compiler better than a manual type argument. It is also worth remembering that unknown is often a safer default than any. A generic can carry unknown through the system until the caller proves what the value really is.

Constraints In Practice

Constraints are most useful when the implementation needs a property or method that is not present on every type. A length check, a key lookup, or a comparison all need a little structure from the caller. The constraint tells TypeScript what that structure is, and the body of the function can rely on it without unsafe casts. That keeps the runtime code simple and the compile-time contract honest.

The more precise the constraint, the more helpful the editor becomes for anyone using the function later. Autocomplete can show the right keys, and mistakes surface early instead of after a build or a test run. This is especially useful for utility libraries and data-access code, where a single wrong property name can cost time across many files. A well-placed generic constraint turns that kind of bug into a fast compiler error.

Generic APIs in practice

The most useful generic APIs are the ones that feel ordinary at the call site. A caller should not need to think about the mechanics of a type parameter just to use a function that clearly matches the data they already have. That is why many well-designed utilities are easy to read even though they are type-heavy under the hood. They move the complexity into the definition and keep the usage simple.

When you review a generic, check whether the type parameter is actually carrying information the caller cares about. If not, the API may be trying to do too much. If yes, the generic is probably doing real work and helping the code stay honest. That small check is often enough to tell the difference between a helpful abstraction and a type puzzle that exists for its own sake.

Generics let you write code that’s both flexible and type-safe:

  • Type parameters (<T>) make functions and classes reusable
  • Type inference means you often don’t need to specify types explicitly
  • Constraints (extends) restrict what types are allowed
  • Default types provide fallback when types aren’t specified

Master generics, and you’ll write TypeScript code that’s more maintainable, more reusable, and catches more errors at compile time.

Next steps

  • Apply these patterns by refactoring a plain function in your codebase to accept a generic type parameter — start with a utility like firstElement or a simple cache wrapper
  • Move on to TypeScript Conditional Types to learn how types can branch based on other types
  • Read the TypeScript Handbook on Generics for the official deep dive

See also