Generic Interfaces & Classes
Build reusable, type-safe structures that work with any type
Just like functions can be generic, interfaces and classes can also be generic. They describe a shape that works with any type, while preserving full type information. This is the backbone of reusable data structures and APIs.
Think of the Hyderabadi Tiffin Carrier — the classic dabba. It has multiple tiers, and each tier holds a different item. But the carrier itself doesn't care what you put in each tier — dal in one, sabzi in another, roti in the third, rice in the bottom. The carrier is the generic interface or class. Each tier is typed by what you put in: Tier<Dal>, Tier<Sabzi>, Tier<Roti>.
The structure of the carrier is fixed — it always has tiers with lids — but the content type is flexible. You get one interface, infinite type possibilities:
interface TiffinCarrier<T> {
items: T[];
add(item: T): void;
get(): T | undefined;
}
One interface — TiffinCarrier<Dal>, TiffinCarrier<Sabzi>, TiffinCarrier<Roti> — all share the same shape, but each is typed for its own content. The entire Promise API, Array API, Map, and Set — they are ALL generic interfaces! When you write number[], you're actually using Array<number>. When you write Promise<User>, you're using a generic interface. Generic interfaces and classes are how TypeScript achieves reusable, type-safe abstractions at scale.
Defining a generic interface is straightforward — add a type parameter in angle brackets after the interface name. The simplest form:
interface Box<T> {
value: T;
}
Now Box<string> means { value: string }, and Box<number> means { value: number }. The type parameter T acts as a placeholder that gets filled in when you use the interface. This is the core mechanism — one definition, many concrete types.
A real-world pattern you'll see everywhere is the API response wrapper. Every API call returns status and message, but the data shape changes per endpoint:
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
Now ApiResponse<User> has data of type User, while ApiResponse<string> has data of type string. One interface handles every endpoint — no duplication, full type safety.
Generic interfaces shine with methods too. A repository pattern is a perfect example — the operations are always the same (find, save, delete), but the entity type changes:
interface Repository<T> {
findById(id: string): T;
save(item: T): void;
findAll(): T[];
delete(id: string): void;
}
Repository<User> — save takes User, findById returns User. Repository<Product> — save takes Product, findById returns Product. Same structure, different types, complete safety.
You can have multiple type parameters too. Key-value pairs are the classic case:
interface KeyValuePair<K, V> {
key: K;
value: V;
}
KeyValuePair<string, number> — key is string, value is number. KeyValuePair<number, boolean> — key is number, value is boolean. Multiple parameters let you express rich type relationships.
Here's the big revelation: you've been using generic interfaces all along! Array<T> is a generic interface — number[] is just shorthand for Array<number>. Promise<T> is a generic interface representing an async value that resolves to type T. Map<K, V> and Set<T> are generic interfaces too.
You can also define function type signatures inside generic interfaces. A transformer converts one type to another:
interface Transformer<T, U> {
transform(input: T): U;
}
This pattern is everywhere — mappers, parsers, converters. Transformer<string, number> transforms strings to numbers. Transformer<User, UserDTO> transforms User objects to DTOs. Generic interfaces are the foundation of type-safe, reusable contracts in TypeScript.
Classes can also have type parameters, and the syntax mirrors generic interfaces perfectly. The type parameter is declared right after the class name:
class Box<T> {
constructor(
private value: T
) {}
getValue(): T {
return this.value;
}
}
Creating instances requires specifying the type argument: new Box<number>(42) creates a box holding a number, new Box<string>("hello") creates a box holding a string. TypeScript can sometimes infer the type from constructor arguments, but explicit specification is always clearer.
const numBox = new Box<number>(42);
const strBox = new Box<string>("hello");
Here's a more practical example — a data store that could hold any entity type:
class DataStore<T> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
getAll(): T[] {
return [...this.items];
}
find(
fn: (item: T) => boolean
): T | undefined {
return this.items.find(fn);
}
}
The type parameter T flows through every method and property. When you create new DataStore<User>(), the add method takes User, getAll returns User[], and find takes a User predicate. The entire class is consistently typed — no leaks, no any, no guesswork.
One of the most powerful patterns is implementing a generic interface with a generic class. This lets you define a contract with the interface and fulfill it with the class:
interface Collection<T> {
add(item: T): void;
size(): number;
}
class List<T> implements Collection<T> {
private items: T[] = [];
add(item: T) {
this.items.push(item);
}
size() {
return this.items.length;
}
}
Critical detail: The class MUST re-declare the type parameter when implementing a generic interface. Writing class B implements A<T> without B having its own <T> is an error — the class needs to declare the parameter to pass it through. The correct form is class B<T> implements A<T>. Think of it this way: the interface says "I need a type parameter", and the class says "I'll provide one and pass it to the interface". The class owns the parameter; the interface consumes it.
Generic structures are powerful, but they come with traps that catch even experienced developers. Let's walk through the most common ones.
Trap 1: Static members cannot use the class type parameter. This is a classic mistake. You might think <T> is available everywhere in the class, but static members are different:
class Box<T> {
static default: T; // ERROR!
}
class Box<T> {
static default: string; // OK!
}
Why? Static members belong to the class itself, not to any particular instance. T is only known when you create an instance like new Box<string>(). But static members exist before any instance is created — there's no T to reference! Use a concrete type for static members instead.
Trap 2: Forgetting to specify the type parameter. When you write new Box() without a type argument, TypeScript tries to infer T. If the constructor has parameters of type T, inference might work. But if there's nothing to infer from, T falls back to unknown in strict mode:
class Store<T> {
items: T[] = [];
}
const s = new Store();
// T is unknown — almost useless!
Always specify the type explicitly when inference isn't possible: new Store<User>(). Don't let TypeScript silently give you unknown.
Trap 3: Method-level vs class-level type parameters. A method can have its OWN type parameter, completely separate from the class's:
class Store<T> {
process<U>(
item: T,
extra: U
): [T, U] {
return [item, extra];
}
}
Here, T is fixed when the instance is created (new Store<User>()), but U varies with each call to process. You could call store.process(user, 42) and store.process(user, true) — U is different each time, but T remains User.
Trap 4: Class not declaring the type parameter when implementing a generic interface.
interface A<T> {
process(item: T): T;
}
// ERROR! B doesn't have <T>
class B implements A<T> {}
// Correct: B declares its own T
class B<T> implements A<T> {
process(item: T): T {
return item;
}
}
The class must declare its own type parameter to pass it to the interface. Without it, TypeScript has no idea what T refers to in the class context.
Time for the rapid-fire cheatsheet. Pin this to your desk, tattoo it on your brain, or write it on the back of your Irani chai receipt — whatever works!
Generic Interface Syntax:
// Single type parameter
interface X<T> {
value: T;
}
// Multiple type parameters
interface X<K, V> {
key: K;
value: V;
}
// With methods
interface X<T> {
get(): T;
set(v: T): void;
}
Generic Class Syntax:
// Basic generic class
class X<T> {
constructor(v: T) {}
}
// Implementing generic interface
class X<T> implements I<T> {
// must re-declare T
}
Built-in Generic Types in TypeScript:
Array<T> // T[] shorthand
Promise<T> // async T
Map<K, V> // key-value pairs
Set<T> // unique values
Record<K, V> // object map
Partial<T> // all props optional
Key Rules to Remember:
- Static members CANNOT use the class type parameter — they belong to the class, not the instance.
- Always specify type arguments when TypeScript can't infer them — don't let T silently become
unknown. - Method-level type parameters are separate from class-level ones —
class X { fn<T>() {} }has a fresh T per call. - When implementing a generic interface, the class MUST declare its own type parameter:
class B<T> implements A<T>.
The golden rule: "Generic interfaces and classes are the Tiffin Carrier — one structure, many fillings. The dabba is the same, but what goes in each tier is up to you, bhai!"
Key Takeaways
- Interfaces and classes can have type parameters, just like functions
- Generic interfaces define reusable contracts: ApiResponse<T>, Repository<T>
- Generic classes flow T through all methods and properties consistently
- Static members can't reference class type parameters — T is per-instance
- Class must re-declare <T> when implementing a generic interface
Want to track your progress?
Log in to save your place and pick up where you left off.
Progress track karna chahte ho?
Login karo apni progress save karne ke liye aur jahan chhoda tha wahan se shuru karo.
Login