RizTech Academy logo
RizTech Academy
GenericsLesson 4 of 525 min

Generic interfaces and classes

Generics are not just for functions — interfaces, type aliases, and classes can be generic too. This is how you build reusable data structures and containers that keep the type of what they hold: a Box<T>, a Repository<T>, a Result<T>. This lesson is generic types, which are the backbone of every typed library and much of your own reusable code.

Generic type aliases and interfaces

A type parameter on a type alias or interface makes the shape generic:

// a generic type alias — a box holding a value of type T
type Box<T> = {
  value: T;
};

const stringBox: Box<string> = { value: "Pune" };   // Box holding a string
const numberBox: Box<number> = { value: 42 };        // Box holding a number

stringBox.value.toUpperCase();   // fine — value is string
numberBox.value.toFixed();       // fine — value is number

Box<T> is a family of types — Box<string>, Box<number>, Box<User> — one per type you fill in. You have used generic types constantly: Array<T>, Promise<T>, Map<K, V>, and the Result<T> and State<T> discriminated unions from the unions module. Anywhere a type "holds" or "produces" some other type, it is a good candidate to be generic:

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

type Result<T, E = string> =           // two type parameters, E defaulting to string
  | { ok: true; value: T }
  | { ok: false; error: E };

const userResponse: ApiResponse<{ name: string }> = {
  data: { name: "Kavita" }, status: 200, timestamp: new Date(),
};

ApiResponse<T> is the same envelope (data, status, timestamp) for any payload type — write it once, use it for every endpoint. This is exactly how you type an API client (the capstone): a generic response wrapper that keeps each endpoint's specific data type.

Generic classes

A class can be generic, which is how you build a container with behaviour that preserves its element type:

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;
  }
}

const numbers = new Stack<number>();
numbers.push(1);
numbers.push(2);
numbers.push("three");    // error: 'string' is not assignable to 'number'
const top = numbers.pop();   // number | undefined

Stack<T> holds Ts and its methods speak in T — push takes a T, pop returns T | undefined. Create a Stack<number> and it is a stack of numbers: pushing a string is an error, and popping gives a number. One class definition, a type-safe stack for any element type. This is how every typed collection library (immutable data structures, queues, trees) is built, and how you would build your own.

Note the type parameter is fixed per instance: new Stack<number>() is forever a stack of numbers. The class's methods can still have their own generic parameters (a map<R> that transforms T to R), as the generic-functions lesson showed.

A generic repository — the everyday pattern

The most common generic class in real applications is a repository — a typed store for entities:

interface HasId {
  id: string;
}

class Repository<T extends HasId> {          // constrained: every T must have an id
  private items = new Map<string, T>();

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

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

  all(): T[] {
    return [...this.items.values()];
  }
}

interface User extends HasId { name: string; }

const users = new Repository<User>();
users.save({ id: "1", name: "Kavita" });
const user = users.findById("1");    // User | undefined — the full type, with name

Repository<T extends HasId> combines everything: a generic class, a constraint (T must have an id), and methods that return the precise entity type. One Repository class serves users, orders, products — every entity that has an id — each fully typed. This is the shape of a data layer in real TypeScript backends, and building one is a rite of passage.

Map<K, V> and multiple type parameters

Generic types often have several type parameters, the canonical example being Map<K, V>:

const prices = new Map<string, number>();    // keys are strings, values are numbers
prices.set("chai", 15);
prices.set("chai", "free");   // error: 'string' is not assignable to 'number' (the VALUE type)
const p = prices.get("chai"); // number | undefined

Map<K, V> is generic in both its key type K and its value type V, and TypeScript checks both — you cannot set a wrong-typed key or value, and get returns V | undefined. Building your own two-type-parameter generic (a typed cache, a bidirectional map) follows the same pattern.

When to make a type generic

The judgement is the same as for functions: make a type generic when it holds or relates another type that should be preserved. A Box, a Response, a Stack, a Repository, a Result — all hold or produce some other type, and being generic keeps that type precise. Do not make a type generic that does not actually vary — a User with fixed fields is not generic; a Container that could hold anything is. And prefer the built-in generic types (Array, Map, Set, Promise, Record) over hand-rolling your own when they fit — you rarely need to write your own Stack because an array does the job, but you will write generic response wrappers, result types, and repositories.

Check your work

What a generic type alias/interface is. A shape with a type parameter — Box<T>, ApiResponse<T> — a family of types, one per filled-in type.

Where you have used generic types. Array<T>, Promise<T>, Map<K,V>, and Result<T>/State<T>.

What a generic class is. A container with behaviour that preserves its element type — Stack<T> holds Ts and its methods speak in T.

Whether a class's type parameter varies per instance. No — new Stack<number>() is fixed as a stack of numbers; methods can have their own generic parameters, though.

The repository pattern. Repository<T extends HasId> — a generic, constrained class returning the precise entity type; the shape of a typed data layer.

Map<K, V>. A generic type with two type parameters, both checked — keys and values typed independently.

When to make a type generic. When it holds or relates another type that should be preserved; not when it does not actually vary; prefer built-in generics when they fit.

Practice

  1. Write a generic Box<T> type alias and use it for a string and a number. Confirm .value has the right type in each.
  2. Write a generic ApiResponse<T> interface and type two different endpoint responses with it.
  3. Write a generic Stack<T> class. Create a Stack<number>, push a number (fine) and a string (error), and confirm pop() returns number | undefined.
  4. Write a generic Repository<T extends HasId> class. Store a User, find one, and confirm the full type comes back.
  5. Add a map<R> method to Box<T> that returns a Box<R>, and use it to turn a Box<number> into a Box<string>.
  6. Use Map<string, number> and confirm both key and value types are checked and get is V | undefined.
  7. For three types in code you know, decide whether each should be generic and justify it.

Official documentation

Next: the built-in utility types — Partial, Pick, Omit and Record.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship