TypeScript Generics Through Practical Examples

1. Overview

Generics is a tool that helps you write reusable code while maintaining type safety. Instead of writing UserRepository, OrderRepository, ProductRepository over and over, you write a single Repository<T> that works for all. This article walks through practical examples: generic functions, generic classes, constraints, multiple type parameters, and how to combine them with utility types. After reading, you’ll feel confident applying generics to service layers, repositories, and API responses in your TypeScript projects.

2. What are generics and why do you need them

Generics allow you to “parameterize” a type, much like a function takes parameters but at the type level.

// Without generics: have to rewrite for each type
function firstString(arr: string[]): string {
  return arr[0];
}
function firstNumber(arr: number[]): number {
  return arr[0];
}

// With generics: one function for any type
function first<T>(arr: T[]): T {
  return arr[0];
}

const name = first(["Alice", "Bob"]); // T = string, inferred automatically
const age = first([30, 25]);          // T = number

<T> is a type parameter — a variable representing the actual type passed at call time. TypeScript infers T automatically based on the argument; you don’t need to specify it manually.

Note: T, U, K, V are just naming conventions with no special meaning. In larger codebases, more descriptive names like TItem, TResponse will be much easier to read.

3. Generic functions in practice: the groupBy function

A common example when processing data: grouping objects by a field.

interface Order {
  id: number;
  status: "pending" | "paid" | "cancelled";
  customerId: number;
}

function groupBy<T, K extends string | number>(
  items: T[],
  keyFn: (item: T) => K
): Record<K, T[]> {
  const result = {} as Record<K, T[]>;
  for (const item of items) {
    const key = keyFn(item);
    (result[key] ??= []).push(item);
  }
  return result;
}

const orders: Order[] = [
  { id: 1, status: "paid", customerId: 10 },
  { id: 2, status: "pending", customerId: 11 },
  { id: 3, status: "paid", customerId: 10 },
];

const byStatus = groupBy(orders, (o) => o.status);
  • T is the type of items in the array (Order).
  • K extends string | number restricts the key to only string or number — because object keys cannot be any other type.
  • The result byStatus has type Record<"pending" | "paid" | "cancelled", Order[]>, so TypeScript knows exactly which keys can appear.
byStatus.paid       -> Order[] (2 items)
byStatus.pending    -> Order[] (1 item)
byStatus.cancelled  -> Order[] (could be undefined in strict mode)

4. Generic constraints with extends

A constraint limits T to satisfy a certain shape, allowing you to safely access properties inside the generic function.

interface HasId {
  id: number;
}

function findById<T extends HasId>(items: T[], id: number): T | undefined {
  return items.find((item) => item.id === id);
}

interface Customer extends HasId {
  name: string;
  email: string;
}

const customers: Customer[] = [
  { id: 1, name: "An", email: "an@example.com" },
  { id: 2, name: "Bình", email: "binh@example.com" },
];

const found = findById(customers, 1); // Customer | undefined

Without extends HasId, the line item.id would error because TypeScript wouldn’t know whether T has an id property.

error TS2339: Property 'id' does not exist on type 'T'.

This is the most common mistake when learning generics: forgetting the constraint and then accessing arbitrary properties on T.

5. Generic class: the Repository<T> example

This is the classic use case — a generic repository for multiple entities.

interface Entity {
  id: number;
}

class Repository<T extends Entity> {
  private items: Map<number, T> = new Map();

  save(item: T): T {
    this.items.set(item.id, item);
    return item;
  }

  findById(id: number): T | undefined {
    return this.items.get(id);
  }

  findAll(): T[] {
    return Array.from(this.items.values());
  }

  deleteById(id: number): boolean {
    return this.items.delete(id);
  }
}

interface Product extends Entity {
  name: string;
  price: number;
}

const productRepo = new Repository<Product>();
productRepo.save({ id: 1, name: "Keyboard", price: 500_000 });
productRepo.save({ id: 2, name: "Mouse", price: 200_000 });

const all = productRepo.findAll(); // Product[]
  • Repository<T extends Entity> ensures every managed entity has an id, so Map<number, T> safely uses id as the key.
  • When you instantiate new Repository<Product>(), every method return type is exactly Product — the IDE autocompletes name, price instantly.

Note: If you use Repository for only one entity across your entire project, generics would be over-engineering. Write a specific class first; only generify when you have ≥ 2 places sharing the same logic.

6. Multiple type parameters: the ApiResponse<TData, TError> example

When you need to parameterize more than one type, use multiple type parameters at once.

interface ApiResponse<TData, TError = string> {
  success: boolean;
  data?: TData;
  error?: TError;
}

function fetchSuccess<T>(data: T): ApiResponse<T> {
  return { success: true, data };
}

function fetchFailure<T, E = string>(error: E): ApiResponse<T, E> {
  return { success: false, error };
}

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

const okResponse: ApiResponse<User> = fetchSuccess({ id: 1, username: "nonladev" });

const errResponse: ApiResponse<User, { code: number; message: string }> = fetchFailure({
  code: 404,
  message: "User not found",
});

TError = string in the interface declaration is a default generic type — if you don’t provide the second type argument, TypeScript automatically uses string. That’s why ApiResponse<User> is valid even with just 1 type argument.

7. Combining generics with utility types

Utility types like Partial, Pick, Omit are themselves generic types built into TypeScript. Combining them with your own generics is very effective for update functions.

interface Customer {
  id: number;
  name: string;
  email: string;
  vip: boolean;
}

function updateEntity<T extends { id: number }>(
  original: T,
  patch: Partial<Omit<T, "id">>
): T {
  return { ...original, ...patch };
}

const customer: Customer = { id: 1, name: "An", email: "an@a.com", vip: false };

const updated = updateEntity(customer, { vip: true });
// updated: Customer { id: 1, name: "An", email: "an@a.com", vip: true }
  • Partial<Omit<T, "id">> means: take all fields of T except id, then make all those fields optional.
  • This way, updateEntity prevents modifying id, and the caller only needs to pass fields they want to change — very similar to the PATCH pattern in REST APIs.

If you try to pass { id: 2 } in the patch parameter, TypeScript will error immediately because id was excluded by Omit:

error TS2345: Object literal may only specify known properties,
and 'id' does not exist in type 'Partial<Omit<Customer, "id">>'.

8. Generics with conditional types: when you need infer

A more advanced step: automatically extract the return type of a function, avoiding manual duplication.

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

async function fetchCustomer(id: number): Promise<Customer> {
  return { id, name: "An", email: "an@a.com", vip: false };
}

type FetchResult = UnwrapPromise<ReturnType<typeof fetchCustomer>>;
// FetchResult = Customer, not Promise<Customer>
  • infer U tells TypeScript to “guess” the type inside Promise<...> and assign it to U.
  • You don’t need to know beforehand what fetchCustomer returns — the type is inferred automatically, and if the fetchCustomer signature changes, FetchResult updates on its own.

Note: Conditional types with infer are very powerful but can make code hard to read. Only use them when you need a reusable type utility (like UnwrapPromise) across many places, not for one-off logic.

9. Writing tests for generic functions

Generics affect type-checking at compile time, but behavior at runtime still needs testing like any other code. Install Vitest:

<!-- package.json (dependencies section, not pom.xml) -->
npm install -D vitest typescript
// repository.test.ts
import { describe, it, expect } from "vitest";

interface Entity {
  id: number;
}

class Repository<T extends Entity> {
  private items: Map<number, T> = new Map();
  save(item: T): T {
    this.items.set(item.id, item);
    return item;
  }
  findById(id: number): T | undefined {
    return this.items.get(id);
  }
}

interface Product extends Entity {
  name: string;
}

describe("Repository<Product>", () => {
  it("saves and retrieves the correct product by id", () => {
    const repo = new Repository<Product>();
    repo.save({ id: 1, name: "Keyboard" });

    const found = repo.findById(1);

    expect(found).toBeDefined();
    expect(found?.name).toBe("Keyboard");
  });

  it("returns undefined when id does not exist", () => {
    const repo = new Repository<Product>();

    const found = repo.findById(999);

    expect(found).toBeUndefined();
  });
});

Run npx vitest run, and you should see:

✓ Repository<Product> > saves and retrieves the correct product by id
✓ Repository<Product> > returns undefined when id does not exist

Test Files  1 passed (1)
     Tests  2 passed (2)

This test doesn’t check types (the TypeScript compiler already did that at build time); it checks the runtime logic of the generic class — ensuring the Map works correctly regardless of what T is.

10. Common mistakes and when not to use generics

  • Over-generifying: creating Repository<T> but your entire project has only one entity. In this case, a specific CustomerRepository class is easier to read and maintain.
  • Forgetting constraints: writing function getProp<T>(obj: T, key: string) then accessing obj[key] will error with type any or index signature issues. Use <T, K extends keyof T>(obj: T, key: K): T[K] instead.
  • Using any instead of generics: function identity(x: any): any completely loses type safety — TypeScript won’t report any errors. Always prefer <T>(x: T): T.
  • Too many type parameters: if a function has 4–5 type parameters, the code becomes very hard to read. Consider consolidating related types into a single config interface.
// Hard to read
function merge<A, B, C, D>(a: A, b: B, c: C, d: D) { /* ... */ }

// Easier to read
interface MergeInput<TBase, TExtra> {
  base: TBase;
  extra: TExtra;
}
function merge<TBase, TExtra>(input: MergeInput<TBase, TExtra>) { /* ... */ }

Note: If a type parameter is used only once in the entire function signature, you probably don’t need a generic — a concrete type or unknown is sufficient.

11. Conclusion

Generics help you write reusable code without sacrificing type safety: one Repository<T>, one ApiResponse<TData, TError> in place of dozens of duplicate classes and interfaces. The key is mastering three things — basic type parameters, constraints with extends, and combining with built-in utility types (Partial, Omit, Pick). Don’t genericize everything from the start; extract a generic only when you see logic repeating in at least two different places in your codebase.

typescriptgenericsjavascripttype-safetyfrontend