Generic Interfaces and Types in TypeScript


Generic Interfaces
An interface can declare type parameters just like a function. Place the type parameter list <T> immediately after the interface name:
interface Box<T> {
value: T;
}
const numBox: Box<number> = { value: 42 };
const strBox: Box<string> = { value: "hello" };Once you provide a type argument (like Box<number>), every occurrence of T in the interface is replaced with number. The result is a concrete type that TypeScript checks just like any other interface.
Generic interfaces can have multiple type parameters, optional properties, and methods — all involving the type parameters:
interface KeyValuePair<K, V> {
key: K;
value: V;
toString(): string;
}
interface Container<T> {
item: T;
count: number;
isEmpty(): boolean;
map<U>(fn: (item: T) => U): Container<U>;
}The map method in Container<T> is itself generic — it introduces a new type parameter U that's local to the method. This pattern mirrors how Array<T>.map<U> works in the standard library.
Generic interfaces can also extend other generic interfaces, mixing their type parameters:
interface Serializable<T> {
serialize(): string;
deserialize(data: string): T;
}
interface PersistableBox<T> extends Box<T>, Serializable<T> {
save(): Promise<void>;
load(): Promise<T>;
}PersistableBox<User> would have value: User, serialize(): string, deserialize(data: string): User, save(), and load(): Promise<User>.
Interfaces vs Type Aliases for Generics
Both interfaces and type aliases support generics. The choice follows the same rules as non-generic types: interfaces are better for object shapes that may be extended or implemented by classes; type aliases are better for union types, intersection types, or when you need mapped/conditional types. Neither is universally superior — use whichever fits the shape.
Generic Type Aliases
Type aliases support the same generic syntax as interfaces. They're especially powerful for union types and complex compositions:
type Maybe<T> = T | null | undefined;
type NonNullable<T> = T extends null | undefined ? never : T; // Built into TypeScript
type Pair<T> = [T, T];
type Triple<A, B, C> = [A, B, C];
type Callback<T> = (value: T) => void;
type AsyncCallback<T> = (value: T) => Promise<void>;
type Setter<T> = (value: T | ((prev: T) => T)) => void; // React setState signatureThe real power comes from parameterised union types. You define the union shape once and specialise it for each data type:
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
// Specific instantiations
type UserResult = Result<User>;
type UserListResult = Result<User[]>;
type StringResult = Result<string, string>; // String errors
function fetchUser(id: number): Promise<Result<User>> {
// ...
}
async function main() {
const result = await fetchUser(1);
if (result.success) {
console.log(result.data.name); // data: User
} else {
console.error(result.error.message); // error: Error
}
}Notice Result<T, E = Error> — type aliases support default type parameters. If you don't provide E, it defaults to Error. This makes common cases concise while still allowing customisation.
Result Types as an Alternative to Throwing
The Result<T, E> pattern — sometimes called Either, Outcome, or ApiResult — is widely used in TypeScript codebases as a structured alternative to try/catch. Errors become values, forcing callers to handle them explicitly. Libraries like neverthrow formalise this pattern, but a simple type alias gets you most of the way there.
Generic Data Structures
Generic types are the natural way to describe reusable data structures. Here are the most common ones you'll see in TypeScript codebases.
Stack:
interface Stack<T> {
push(item: T): void;
pop(): T | undefined;
peek(): T | undefined;
isEmpty(): boolean;
size: number;
}
class ArrayStack<T> implements 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 isEmpty(): boolean {
return this.items.length === 0;
}
get size(): number {
return this.items.length;
}
}
const numStack = new ArrayStack<number>();
numStack.push(1);
numStack.push(2);
numStack.pop(); // 2 — TypeScript knows this is number | undefinedLinked list node:
interface ListNode<T> {
value: T;
next: ListNode<T> | null;
}
// Self-referencing generic type — ListNode<T> contains ListNode<T>
const list: ListNode<number> = {
value: 1,
next: {
value: 2,
next: {
value: 3,
next: null,
},
},
};Binary tree node:
interface TreeNode<T> {
value: T;
left: TreeNode<T> | null;
right: TreeNode<T> | null;
}
function insert<T>(
node: TreeNode<T> | null,
value: T,
compare: (a: T, b: T) => number
): TreeNode<T> {
if (!node) return { value, left: null, right: null };
if (compare(value, node.value) < 0) {
return { ...node, left: insert(node.left, value, compare) };
}
return { ...node, right: insert(node.right, value, compare) };
}The compare function is a generic dependency injection point — the tree works with any type T as long as you provide a comparison function. This pattern is common in type-safe sorting and searching utilities.
Nested Generic Types
Generic types can be nested — a Result<Paginated<User>> is a Result type parameterised with a Paginated type parameterised with User. This composition is what makes generic types so powerful.
First, define the reusable building blocks:
type Paginated<T> = {
items: T[];
total: number;
page: number;
pageSize: number;
hasNextPage: boolean;
};
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
type ApiResponse<T> = Promise<Result<T>>;Then compose them:
type PaginatedUsers = Paginated<User>;
type UserListResponse = ApiResponse<Paginated<User>>;
async function getUsers(page: number): UserListResponse {
// ...
}
const response = await getUsers(1);
if (response.success) {
const { items, total, hasNextPage } = response.data;
// items: User[]
// total: number
// hasNextPage: boolean
items.forEach((user) => console.log(user.name)); // user: User
}TypeScript fully resolves the nested types. response.data is Paginated<User>, so response.data.items is User[] and response.data.items[0].name is a valid access.
Deeply nested generics can make type error messages harder to read. If you find TypeScript printing Result<Paginated<ApiResponse<User>>> in error output, consider using type aliases at each level to give them readable names — both for your own understanding and for better editor hover text.
Real-World Examples
Repository pattern:
interface Repository<T, ID = number> {
findById(id: ID): Promise<T | null>;
findAll(): Promise<T[]>;
findWhere(predicate: Partial<T>): Promise<T[]>;
save(entity: T): Promise<T>;
delete(id: ID): Promise<void>;
}
interface User {
id: number;
name: string;
email: string;
}
// UserRepository fulfils the full contract for User
class UserRepository implements Repository<User> {
async findById(id: number): Promise<User | null> {
// database call
return null;
}
async findAll(): Promise<User[]> {
return [];
}
async findWhere(predicate: Partial<User>): Promise<User[]> {
return [];
}
async save(user: User): Promise<User> {
return user;
}
async delete(id: number): Promise<void> {}
}The Repository<T, ID> interface describes the contract for any data entity. UserRepository implements Repository<User> — TypeScript verifies that every method signature matches, with User substituted for T and number for ID.
Generic form state:
type FieldError = string | null;
type FormState<T> = {
values: T;
errors: Partial<Record<keyof T, FieldError>>;
touched: Partial<Record<keyof T, boolean>>;
isSubmitting: boolean;
isValid: boolean;
};
type LoginForm = {
email: string;
password: string;
};
const loginState: FormState<LoginForm> = {
values: { email: "", password: "" },
errors: { email: "Required" },
touched: { email: true },
isSubmitting: false,
isValid: false,
};FormState<LoginForm> gives you values: LoginForm, errors: { email?: FieldError; password?: FieldError }, and touched: { email?: boolean; password?: boolean }. TypeScript ensures every key in errors and touched is a valid key of the form type.
Observable / event stream:
interface Observable<T> {
subscribe(observer: Observer<T>): Subscription;
pipe<U>(operator: (source: Observable<T>) => Observable<U>): Observable<U>;
map<U>(fn: (value: T) => U): Observable<U>;
filter(predicate: (value: T) => boolean): Observable<T>;
}
interface Observer<T> {
next(value: T): void;
error(err: Error): void;
complete(): void;
}
interface Subscription {
unsubscribe(): void;
}This is a simplified version of the RxJS Observable<T> interface. The pipe method introduces a new type parameter U for the output observable, and map<U> mirrors the pattern from Array<T>.map<U>.
Standard Library Generic Interfaces
TypeScript's standard library is built from generic interfaces. Promise<T>, Map<K, V>, Set<T>, ReadonlyArray<T>, Iterable<T>, AsyncIterable<T> — all generic. Reading their type definitions in lib.es2015.d.ts is one of the best ways to see real-world generic interface patterns. In VS Code, Cmd/Ctrl-click on any built-in type to jump to its declaration.
Best Practices
Keep type parameters to a minimum. Two type parameters is manageable; four starts getting unwieldy. If a type needs many parameters, consider whether it should be split into smaller, composable types.
Use default type parameters for common cases:
// Without default — callers always provide both
type Result<T, E> = { success: true; data: T } | { success: false; error: E };
// With default — Error is the common case, string errors are the exception
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };Name type parameters clearly in interfaces. Single-letter names are fine for generic functions with simple, obvious roles. In interfaces with multiple parameters, descriptive names reduce confusion:
// Less clear — what is K vs V?
interface Cache<K, V> {
get(key: K): V | undefined;
}
// Clearer — especially in documentation and error messages
interface Cache<TKey, TValue> {
get(key: TKey): TValue | undefined;
}Derive types from generic interfaces instead of duplicating:
interface Repository<T> {
findById(id: number): Promise<T | null>;
save(entity: T): Promise<T>;
}
// Don't re-declare — derive
type UserFinder = Pick<Repository<User>, "findById">;
type UserSaver = Pick<Repository<User>, "save">;Key Takeaways
- Generic interfaces declare type parameters after the name:
interface Box<T>— every occurrence ofTis replaced with the concrete type at instantiation - Generic type aliases are ideal for union types and complex compositions:
type Result<T, E = Error>with default type parameters handles common cases concisely - Self-referencing generic types model recursive structures:
ListNode<T>containsListNode<T> | null - Nested generics compose reusable building blocks:
Result<Paginated<User>>is fully type-safe — TypeScript resolves every level - The repository pattern, form state, and API response types are the most common real-world applications of generic interfaces
Generics Foundation Complete
You've covered the core of TypeScript generics: why they exist, how inference
works in functions, and how to build reusable data structures with generic
interfaces and type aliases. The next posts in this series go deeper — generic
constraints with extends, default type parameters, and eventually
conditional and mapped types. The fundamentals you've built here apply
directly to all of them.