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

Generic Functions and Type Inference in TypeScript
TypeScript
8m
May 24, 2026

Generic Functions and Type Inference in TypeScript

TypeScript's type inference engine is smart — most of the time it figures out generic type parameters without any help. But knowing *how* inference works lets you write generics that always infer correctly, and knowing *when* it falls short lets you provide explicit type arguments at exactly the right moments.

#Generic Functions#Type Inference+5
Discriminated Unions and Tagged Types in TypeScript
TypeScript
10m
May 3, 2026

Discriminated Unions and Tagged Types in TypeScript

Discriminated unions are one of TypeScript's most powerful patterns for modelling state. A shared literal property acts as a tag, letting TypeScript narrow each union member precisely — and flag missing cases before they reach production.

#Discriminated Unions#Tagged Types+5
Literal Types and Const Assertions in TypeScript
TypeScript
7m
Apr 19, 2026

Literal Types and Const Assertions in TypeScript

A practical guide to TypeScript literal types and as const — narrowing values to exact possibilities, building type-safe enumerations, and preserving tuple and object shapes.

#Literal Types#Const Assertions+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.