Skip to content
JL

Home

About

Blog

Contact

Shop

Portfolio

Privacy

TOS

Click to navigate

  1. Home
  2. Joshua R. Lehman's Blog
  3. Default Generic Parameters in TypeScript

Table of contents

  • Share on X
  • Discuss on X

Related Articles

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
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
Ask me anything! 💬

© Joshua R. Lehman

Full Stack Developer

Crafted with passion • Built with modern web technologies

2026 • All rights reserved

Contents

  • What Are Default Type Parameters
  • Syntax and Basic Examples
  • Defaults on Multiple Parameters
  • Combining Defaults with Constraints
  • Real-World Examples
  • Best Practices
  • Key Takeaways
TypeScript

Default Generic Parameters in TypeScript

June 21, 2026•8 min read
Joshua R. Lehman
Joshua R. Lehman
Author
TypeScript default generic parameters for optional type arguments
Default Generic Parameters in TypeScript

What Are Default Type Parameters

Function parameters can have default values — function greet(name = "World") works whether you pass "Alice" or nothing at all. Generic type parameters work the same way. You can declare a fallback type that TypeScript uses when the caller doesn't provide one.

This is the feature that makes Promise<void> valid (not Promise<undefined>), lets you write Result<User> instead of Result<User, Error>, and generally makes generic types feel far less verbose in the common case while remaining fully customisable when needed.

Default Parameters Since TypeScript 2.3

Default generic type parameters were added in TypeScript 2.3. They work for type aliases, interfaces, classes, and functions. The syntax T = DefaultType is modelled directly on JavaScript's default function parameters.

Syntax and Basic Examples

Add a default by writing = DefaultType after the type parameter name:

// Without a default — callers must always provide both
type Result<T, E> = { success: true; data: T } | { success: false; error: E };
 
// With a default — Error is the assumed error type
type Result<T, E = Error> =
  | { success: true; data: T }
  | { success: false; error: E };
 
// Usage: one argument is enough for the common case
type UserResult = Result<User>; // Result<User, Error>
type StringResult = Result<string>; // Result<string, Error>
type CustomResult = Result<User, string>; // Result<User, string> — default overridden

Interfaces and classes use the same syntax:

interface Container<T = string> {
  value: T;
  transform<U = T>(fn: (v: T) => U): Container<U>;
}
 
class Box<T = unknown> {
  constructor(public value: T) {}
 
  map<U = T>(fn: (v: T) => U): Box<U> {
    return new Box(fn(this.value));
  }
}
 
const box = new Box(42); // Box<number> — inferred from constructor
const defaultBox = new Box(); // Error: constructor requires a value — defaults don't fill in arguments

Default Types Are Not Default Values

T = string provides a default type when no type argument is supplied. It does not provide a default value for any parameter or property. new Box() still requires you to pass a value argument to the constructor — the default type only affects TypeScript's inference, not runtime behaviour.

Defaults on Multiple Parameters

When multiple type parameters have defaults, required parameters must come before optional ones — the same rule as JavaScript function parameters:

// Valid: required T comes before optional E
type ApiResult<T, E = Error> = { data: T } | { error: E };
 
// Invalid: optional parameter before required one
type BadType<T = string, U> = [T, U]; // Error: Required type parameters may not follow optional type parameters

When a type has two defaulted parameters, you can override just the first, just both, or neither:

type Paginated<T = unknown, Meta = { total: number }> = {
  items: T[];
  meta: Meta;
};
 
type UserPage = Paginated<User>;
// { items: User[]; meta: { total: number } }
 
type UserPageWithCursor = Paginated<User, { total: number; cursor: string }>;
// { items: User[]; meta: { total: number; cursor: string } }
 
type RawPage = Paginated;
// { items: unknown[]; meta: { total: number } } — both defaults applied

TypeScript applies defaults left to right. If you provide one argument, it fills T; the second still gets its default. You cannot skip T to only override Meta — if you need that pattern, consider splitting the type or using an options object.

Combining Defaults with Constraints

Defaults and constraints can work together on the same type parameter. The constraint restricts what types are valid; the default specifies which valid type to use when none is provided:

interface Identifiable {
  id: number | string;
}
 
// T must extend Identifiable, and defaults to a basic entity shape
type EntityStore<T extends Identifiable = { id: number; name: string }> = {
  items: Map<T["id"], T>;
  add(entity: T): void;
  get(id: T["id"]): T | undefined;
};
 
// Uses the default
type DefaultStore = EntityStore;
// { items: Map<number, { id: number; name: string }>; ... }
 
// Provides a specific entity
type UserStore = EntityStore<User>;
// { items: Map<User["id"], User>; ... }
 
// Invalid — violates the constraint
type NumberStore = EntityStore<number>; // Error: number does not satisfy Identifiable

The constraint ensures the default itself is valid. If the default type doesn't satisfy the constraint, TypeScript will flag it immediately.

// A common pattern: constrain and default together
function createRepository<
  T extends { id: number } = { id: number; name: string },
>(items: T[] = []) {
  const store = new Map(items.map((item) => [item.id, item]));
  return {
    get: (id: number): T | undefined => store.get(id),
    all: (): T[] => Array.from(store.values()),
  };
}
 
const defaultRepo = createRepository(); // T = { id: number; name: string }
const userRepo = createRepository<User>([]); // T = User

Real-World Examples

Result type with customisable error:

type Result<T, E = Error> =
  | { success: true; data: T }
  | { success: false; error: E };
 
// These are all valid — the default covers the common case
async function fetchUser(id: number): Promise<Result<User>> {
  /* ... */
}
async function parseCSV(raw: string): Promise<Result<string[], string>> {
  /* ... */
}
 
const result = await fetchUser(1);
if (!result.success) {
  result.error.message; // Error — TypeScript knows it's the built-in Error type
}

Paginated API response:

type PaginatedResponse<T, Cursor = string> = {
  items: T[];
  nextCursor: Cursor | null;
  total: number;
};
 
type UserList = PaginatedResponse<User>; // nextCursor: string | null
type ProductList = PaginatedResponse<Product, number>; // nextCursor: number | null

Builder pattern with default output type:

class QueryBuilder<T = Record<string, unknown>> {
  private conditions: string[] = [];
 
  where(condition: string): this {
    this.conditions.push(condition);
    return this;
  }
 
  build(): string {
    return `SELECT * WHERE ${this.conditions.join(" AND ")}`;
  }
 
  async execute(): Promise<T[]> {
    // Run query and return typed results
    return [];
  }
}
 
// Uses the default — returns Record<string, unknown>[]
const rawQuery = new QueryBuilder().where("active = true").execute();
 
// Provides a specific type — returns User[]
const userQuery = new QueryBuilder<User>().where("active = true").execute();

Event emitter with a default event map:

type DefaultEvents = {
  error: Error;
  dispose: void;
};
 
class EventEmitter<Events extends DefaultEvents = DefaultEvents> {
  private handlers = new Map<keyof Events, Set<(data: unknown) => void>>();
 
  on<K extends keyof Events>(
    event: K,
    handler: (data: Events[K]) => void
  ): void {
    if (!this.handlers.has(event)) this.handlers.set(event, new Set());
    this.handlers.get(event)!.add(handler as (data: unknown) => void);
  }
 
  emit<K extends keyof Events>(event: K, data: Events[K]): void {
    this.handlers.get(event)?.forEach((h) => h(data));
  }
}
 
// Works with just error and dispose events
const emitter = new EventEmitter();
emitter.on("error", (err) => console.error(err.message));
 
// Extends the default event map
interface FileEvents extends DefaultEvents {
  opened: { path: string };
  closed: { path: string };
}
const fileEmitter = new EventEmitter<FileEvents>();
fileEmitter.on("opened", ({ path }) => console.log(`Opened: ${path}`));
fileEmitter.on("error", (err) => console.error(err.message)); // Still available

Ergonomics Without Sacrifice

Default type parameters are how well-designed libraries make the common case simple and the advanced case possible. You've seen this in the TypeScript standard library: Promise<void>, Map<string, string>, Array<unknown> — all using defaults under the hood. Now you can apply the same design to your own generic types.

Best Practices

Choose defaults that represent the most common use case. If 90% of callers use Error as the error type, defaulting to Error makes their code cleaner. If there's no obvious common case, skip the default.

Keep defaults simple. A complex default type (one that spans multiple lines) is a sign the type might be too complicated. Prefer named interfaces over inline defaults:

// Hard to read
type Store<T extends { id: number } = { id: number; name: string; createdAt: Date }> = ...
 
// Clearer
interface BaseEntity { id: number; name: string; createdAt: Date; }
type Store<T extends { id: number } = BaseEntity> = ...

Required parameters always come first. TypeScript enforces this, but it's also good design — callers filling in just the first argument get a sensible result from the defaults on the remaining ones.

Document what the default is and why. When a default is non-obvious, a short comment saves future readers from having to trace the type:

// Default error type is Error; pass a string for APIs that return error messages directly
type Result<T, E = Error> =
  | { success: true; data: T }
  | { success: false; error: E };

Defaults Interact With Inference

When TypeScript can infer a type argument from context, it uses the inferred type — not the default. Defaults only kick in when no type is provided at all and inference doesn't apply. So new Box(42) gives you Box<number> (inferred), not Box<unknown> (default). Inference always wins over defaults.

Key Takeaways

  • Default type parameters use = DefaultType syntax: type Result<T, E = Error> — the default applies when no type argument is provided
  • Required type parameters must come before optional (defaulted) ones — the same rule as JavaScript function parameters
  • TypeScript uses inferred types over defaults — defaults only apply when the caller provides no type argument and inference doesn't resolve one
  • Constraints and defaults can be combined: T extends Identifiable = BaseEntity — the default must satisfy the constraint
  • Defaults are a design tool for ergonomics — pick the most common case as the default to reduce boilerplate for most callers while keeping customisation available

Generics Series Complete

You've now covered the full generics foundation: why generics exist, inference in functions, generic interfaces and types, generic classes, constraints with extends, and default parameters. These six posts are the bedrock of TypeScript's type system mastery. The next phase of the series moves into conditional types, mapped types, and template literal types — where you'll use everything you learned here to manipulate types themselves.