Generic Interfaces & Types in TypeScript
Generic interfaces and type aliases let you define reusable data shapes that are parameterized by one or more types. Instead of duplicating nearly identical interfaces for every domain entity, you write one generic contract that the compiler specialises at each use site.
Generic Interfaces
Attach a type parameter list directly after the interface name to make it generic. Every property or method signature can reference those parameters.
// A generic "box" that can hold any value
interface Box<T> {
value: T;
label: string;
}
const numberBox: Box<number> = { value: 42, label: 'age' };
const stringBox: Box<string> = { value: 'hello', label: 'greeting' };
const boolBox: Box<boolean> = { value: true, label: 'active' };
// TypeScript catches type mismatches
// const bad: Box<number> = { value: 'oops', label: 'x' }; // ❌Generic Type Aliases
Type aliases can be generic too, and they support features interfaces cannot — like union types and conditional types.
// Generic type alias
type Pair<A, B> = {
first: A;
second: B;
};
type Nullable<T> = T | null;
type Maybe<T> = T | null | undefined;
const p: Pair<string, number> = { first: 'Alice', second: 30 };
const n: Nullable<string> = null; // or a string
const m: Maybe<boolean> = undefined; // null, undefined, or booleaninterface when describing object shapes that others may extend. Prefer type for unions, intersections, mapped types, and conditional types.Generic API Response Pattern
One of the most useful applications of generic interfaces is modelling API responses. Instead of writing a separate response type for every endpoint, you define one generic wrapper.
interface ApiResponse<T> {
data: T;
status: number;
message: string;
timestamp: string;
}
interface User {
id: number;
name: string;
email: string;
}
interface Post {
id: number;
title: string;
body: string;
}
// Specialise for each endpoint
type UserResponse = ApiResponse<User>;
type PostResponse = ApiResponse<Post>;
type UserListResponse = ApiResponse<User[]>;
// Usage
const response: UserResponse = {
data: { id: 1, name: 'Alice', email: 'alice@example.com' },
status: 200,
message: 'OK',
timestamp: new Date().toISOString(),
};
console.log(response.data.name); // 'Alice' — fully typedGeneric Repository / Service Interfaces
Generic interfaces are ideal for defining contracts for data-access layers (repositories, services, DAOs) where the same operations apply to any entity.
// Generic CRUD repository contract
interface Repository<TEntity, TId = number> {
findById(id: TId): Promise<TEntity | null>;
findAll(): Promise<TEntity[]>;
create(entity: Omit<TEntity, 'id'>): Promise<TEntity>;
update(id: TId, partial: Partial<TEntity>): Promise<TEntity>;
delete(id: TId): Promise<void>;
}
// Implement once per entity
class UserRepository implements Repository<User> {
async findById(id: number): Promise<User | null> {
// fetch from DB
return null;
}
async findAll(): Promise<User[]> { return []; }
async create(entity: Omit<User, 'id'>): Promise<User> {
return { id: Math.random(), ...entity };
}
async update(id: number, partial: Partial<User>): Promise<User> {
return { id, name: '', email: '', ...partial };
}
async delete(_id: number): Promise<void> {}
}
class PostRepository implements Repository<Post> {
async findById(id: number): Promise<Post | null> { return null; }
async findAll(): Promise<Post[]> { return []; }
async create(entity: Omit<Post, 'id'>): Promise<Post> {
return { id: Math.random(), ...entity };
}
async update(id: number, partial: Partial<Post>): Promise<Post> {
return { id, title: '', body: '', ...partial };
}
async delete(_id: number): Promise<void> {}
}Generic Interfaces with Multiple Type Parameters
// A generic key-value store
interface Store<TKey, TValue> {
get(key: TKey): TValue | undefined;
set(key: TKey, value: TValue): void;
has(key: TKey): boolean;
delete(key: TKey): boolean;
keys(): Iterable<TKey>;
values(): Iterable<TValue>;
}
// A transformer pipeline step
interface Transform<TInput, TOutput> {
transform(input: TInput): TOutput;
canTransform(input: unknown): input is TInput;
}
// Paginated list result
interface Page<T> {
items: T[];
total: number;
page: number;
pageSize: number;
hasNext: boolean;
hasPrev: boolean;
}
const userPage: Page<User> = {
items: [],
total: 0,
page: 1,
pageSize: 20,
hasNext: false,
hasPrev: false,
}Extending Generic Interfaces
Generic interfaces can extend other interfaces — both generic and non-generic. The extending interface can forward, rebind, or add type parameters.
interface Identifiable {
id: number;
}
// Add an id to any generic entity
interface Entity<T> extends Identifiable {
data: T;
createdAt: Date;
}
// Further extend: add audit fields
interface AuditedEntity<T> extends Entity<T> {
createdBy: string;
updatedAt: Date;
updatedBy: string;
}
const auditedUser: AuditedEntity<User> = {
id: 1,
data: { id: 1, name: 'Alice', email: 'alice@example.com' },
createdAt: new Date(),
createdBy: 'system',
updatedAt: new Date(),
updatedBy: 'admin',
};Generic Function Types in Interfaces
Methods and properties of interface members can themselves be generic, independent of the interface's own type parameters.
// Interface with a generic method (T is the interface param, U is local to the method)
interface Mapper<T> {
map<U>(fn: (item: T) => U): U[];
flatMap<U>(fn: (item: T) => U[]): U[];
filter(predicate: (item: T) => boolean): T[];
}
// Event emitter with type-safe payloads
interface EventMap {
click: { x: number; y: number };
keydown: { key: string; code: string };
resize: { width: number; height: number };
}
interface TypedEventEmitter<TEvents extends Record<string, unknown>> {
on<K extends keyof TEvents>(event: K, handler: (payload: TEvents[K]) => void): void;
off<K extends keyof TEvents>(event: K, handler: (payload: TEvents[K]) => void): void;
emit<K extends keyof TEvents>(event: K, payload: TEvents[K]): void;
}
declare const emitter: TypedEventEmitter<EventMap>;
// Fully type-safe event handling
emitter.on('click', ({ x, y }) => {
console.log(`Clicked at ${x}, ${y}`);
});
// emitter.emit('click', { key: 'a' }); // ❌ Error: wrong payload shapeTypedEventEmitter) is used by libraries like socket.io and typed-emitter. Understanding it helps you read and write professional TypeScript libraries.Generic Type Aliases — Advanced Patterns
// Recursive generic types
type Tree<T> = {
value: T;
children: Tree<T>[];
};
const tree: Tree<number> = {
value: 1,
children: [
{ value: 2, children: [] },
{ value: 3, children: [{ value: 4, children: [] }] },
],
};
// Generic discriminated union
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
function divide(a: number, b: number): Result<number, string> {
if (b === 0) return { success: false, error: 'Division by zero' };
return { success: true, data: a / b };
}
const result = divide(10, 2);
if (result.success) {
console.log(result.data); // number — narrowed by discriminant
} else {
console.error(result.error); // string
}Interface vs Type Alias — Generic Context
Feature | interface | type alias |
|---|---|---|
Generic parameters | Yes | Yes |
Declaration merging | Yes | No |
Extends other types | Yes (extends) | Yes (& intersection) |
Unions | No | Yes |
Conditional types | No | Yes |
Recursive definitions | Yes (direct) | Yes (with workarounds) |
Mapped types | No | Yes |
Error messages | Shows interface name | Expands inline |
Generic Utility Type Building Blocks
// Building blocks for domain modelling
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
type Required<T, K extends keyof T> = Omit<T, K> & { [P in K]-?: T[P] };
// Make specific fields nullable
type Nullable<T, K extends keyof T> = Omit<T, K> & {
[P in K]: T[P] | null;
};
// Form state wrapper
interface FormField<T> {
value: T;
error: string | null;
touched: boolean;
dirty: boolean;
}
type FormState<T> = {
[K in keyof T]: FormField<T[K]>;
};
type UserForm = FormState<User>;
// { id: FormField<number>; name: FormField<string>; email: FormField<string> }Generic Interfaces for State Management
State management libraries and patterns heavily rely on generic interfaces. Here is a simplified but realistic Redux-like store interface.
// Generic action type
interface Action<TType extends string = string, TPayload = undefined> {
type: TType;
payload: TPayload;
}
// Generic reducer
type Reducer<TState, TAction extends Action> = (
state: TState,
action: TAction
) => TState;
// Generic store
interface Store<TState, TAction extends Action> {
getState(): TState;
dispatch(action: TAction): void;
subscribe(listener: (state: TState) => void): () => void;
}
// Concrete usage
interface CounterState {
count: number;
lastUpdated: Date | null;
}
type IncrementAction = Action<'INCREMENT', { by: number }>;
type ResetAction = Action<'RESET', undefined>;
type CounterAction = IncrementAction | ResetAction;
const counterReducer: Reducer<CounterState, CounterAction> = (state, action) => {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + action.payload.by, lastUpdated: new Date() };
case 'RESET':
return { count: 0, lastUpdated: new Date() };
default:
return state;
}
};Merging Generic Interfaces (Declaration Merging)
Unlike type aliases, interfaces support declaration merging — you can split or extend an interface across multiple declarations. This is particularly useful for augmenting third-party library types.
// Base declaration (from a library, for example)
interface Theme<T = object> {
colors: T;
spacing: number[];
}
// Your extension — merges with the base
interface Theme<T = object> {
fontFamily: string;
borderRadius: number;
}
// The merged interface has all four properties
const myTheme: Theme<{ primary: string }> = {
colors: { primary: '#3b82f6' },
spacing: [4, 8, 16, 32],
fontFamily: 'Inter',
borderRadius: 4,
};interface, not type. Use it sparingly — it can make types hard to trace when the declarations are in different files.Quick Reference
interface Foo<T> — declare a generic interface
type Foo<T> — declare a generic type alias
Interfaces can extend generic interfaces: interface Bar<T> extends Foo<T>
Methods can be independently generic: doSomething<U>(fn: (t: T) => U): U
Generic interfaces model API responses, repositories, event emitters, and form state
type Result<T, E = Error> — default type params work on type aliases too
Only interfaces support declaration merging — use this for library augmentation