Skip to content
JL

Home

About

Blog

Contact

Shop

Portfolio

Privacy

TOS

Click to navigate

  1. Home
  2. Joshua R. Lehman's Blog
  3. Generic Interfaces and Types in TypeScript

Table of contents

  • Share on X
  • Discuss on X

Related Articles

Default Generic Parameters in TypeScript
TypeScript
6m
Jun 21, 2026

Default Generic Parameters in TypeScript

Default generic parameters let you make type arguments optional by providing a fallback type when the caller doesn't specify one. They're the reason Result<User> works without writing Result<User, Error> every time — the Error default is already baked in.

#Default Generic Parameters#TypeScript Generics+4
Generic Constraints with extends in TypeScript
TypeScript
9m
Jun 14, 2026

Generic Constraints with extends in TypeScript

Generic constraints with extends let you limit what types a type parameter can accept while unlocking the specific properties and methods of those types. Instead of writing a generic function that can't do anything useful, you constrain T so TypeScript knows exactly what capabilities it has.

#Generic Constraints#TypeScript extends+5
Generic Classes in TypeScript
TypeScript
9m
Jun 7, 2026

Generic Classes in TypeScript

Generic classes let you write a single class that works correctly for any type you provide. TypeScript's built-in Map, Set, and Promise are all generic classes under the hood. In this post, you'll build your own — from a simple typed stack to a fully type-safe event emitter.

#Generic Classes#TypeScript Generics+5
Ask me anything! 💬

© Joshua R. Lehman

Full Stack Developer

Crafted with passion • Built with modern web technologies

2026 • All rights reserved

Contents

  • Generic Interfaces
  • Generic Type Aliases
  • Generic Data Structures
  • Nested Generic Types
  • Real-World Examples
  • Best Practices
  • Key Takeaways
TypeScript

Generic Interfaces and Types in TypeScript

May 31, 2026•8 min read
Joshua R. Lehman
Joshua R. Lehman
Author
TypeScript generic interfaces and type aliases for reusable data structures
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 signature

The 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 | undefined

Linked 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 of T is 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> contains ListNode<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.