Skip to main content

TS Ep 31: Default Generic Arguments (``)

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
typescript - This article is part of a series.
Part 31: This Article
Forcing consumers of your library or API contract to specify every single generic parameter every time they use an interface leads to verbose, noisy code. Default Generic Arguments (<T = Fallback>) allow you to make generic type parameters optional by providing intelligent fallback defaults.

1. The Problem with Required Generic Parameters
#

Consider an API Response interface defined with two generic type parameters (TData and TError):

// Both TData and TError are required type parameters
interface ApiResponse<TData, TError> {
  status: number;
  message: string;
  data: TData | null;
  error: TError | null;
}

Every time a developer uses ApiResponse, they MUST explicitly specify both type parameters—even for standard endpoints where errors are simple Error objects:

// ❌ VERBOSE: Forced to specify 'Error' every single time!
const res1: ApiResponse<{ id: string }, Error> = {
  status: 200,
  message: "OK",
  data: { id: "101" },
  error: null,
};

// ❌ Compiler Error: Generic type 'ApiResponse<TData, TError>' requires 2 type argument(s).
// const res2: ApiResponse<{ id: string }> = { ... };

2. Defining Default Generic Types (<T = Default>)
#

By assigning a fallback type using the = syntax, you make a generic type parameter optional. If the consumer omits the type argument, TypeScript automatically uses the fallback type:

// 🟢 TError defaults to 'Error', TData defaults to 'unknown'
interface ApiResponse<TData = unknown, TError = Error> {
  status: number;
  message: string;
  data: TData | null;
  error: TError | null;
}

// 1. Omit BOTH type parameters (TData = unknown, TError = Error):
const defaultRes: ApiResponse = {
  status: 500,
  message: "Internal Error",
  data: null,
  error: new Error("Database offline"),
};

// 2. Specify ONLY TData (TError automatically defaults to 'Error'):
const userRes: ApiResponse<{ name: string }> = {
  status: 200,
  message: "OK",
  data: { name: "Alice" },
  error: null,
};

// 3. Override BOTH TData and TError explicitly:
const customErrorRes: ApiResponse<{ name: string }, { code: number; detail: string }> = {
  status: 400,
  message: "Validation Failed",
  data: null,
  error: { code: 1044, detail: "Email invalid" },
};

3. Combining Constraints with Defaults (<T extends Bound = Default>)
#

You can combine Generic Constraints (extends) with Default Arguments (=) in a single declaration:

The syntax order is: <T extends Constraint = DefaultType>

// 1. Constraint: TElement MUST be an HTMLElement (or subclass)
// 2. Default: Defaults to HTMLDivElement if omitted
interface UIComponent<TElement extends HTMLElement = HTMLDivElement> {
  element: TElement;
  mount(container: HTMLElement): void;
}

// 🟢 Defaults to UIComponent<HTMLDivElement>
const defaultDivComponent: UIComponent = {
  element: document.createElement("div"),
  mount(parent) {
    parent.appendChild(this.element);
  },
};

// 🟢 Explicitly specified as UIComponent<HTMLButtonElement>
const buttonComponent: UIComponent<HTMLButtonElement> = {
  element: document.createElement("button"),
  mount(parent) {
    parent.appendChild(this.element);
  },
};

// ❌ Compiler Error: Type 'string' does not satisfy the constraint 'HTMLElement'.
// const invalidComponent: UIComponent<string>;

4. Parameter Ordering Rules
#

Just like standard JavaScript function default parameters (function fn(a, b = 10)), required generic parameters MUST precede optional generic parameters:

// 🟢 VALID ORDER: Required parameter 'K' comes before optional parameter 'V'
type ValidMap<K extends string, V = unknown> = Record<K, V>;

// ❌ INVALID ORDER: Compiler Error! Required type parameters may not follow optional type parameters.
/*
type InvalidMap<V = unknown, K extends string> = {
  key: K;
  value: V;
};
*/

5. Real-World Architecture: State Store Pattern
#

Default generic arguments are heavily utilized in enterprise state management libraries (such as Redux Toolkit or Zustand) to provide clean default signatures while supporting custom action types:

interface BaseAction {
  type: string;
}

// TState is required, TAction defaults to BaseAction
class StateStore<TState, TAction extends BaseAction = BaseAction> {
  private state: TState;

  constructor(initialState: TState) {
    this.state = initialState;
  }

  public getState(): TState {
    return this.state;
  }

  public dispatch(action: TAction): void {
    console.log(`[Dispatching Action]: ${action.type}`);
  }
}

// Usage 1: Simple Store (uses default BaseAction)
const simpleStore = new StateStore({ count: 0 });
simpleStore.dispatch({ type: "INCREMENT" });

// Usage 2: Strict Store with Discriminated Action Union
type CustomAction = { type: "SET_COUNT"; payload: number } | { type: "RESET" };

const strictStore = new StateStore<{ count: number }, CustomAction>({ count: 0 });
strictStore.dispatch({ type: "SET_COUNT", payload: 5 });

// ❌ Compiler Error: Type '"UNKNOWN_ACTION"' is not assignable to type 'CustomAction'.
// strictStore.dispatch({ type: "UNKNOWN_ACTION" });

Summary & Next Steps
#

In this episode:

  • We learned how Default Generic Arguments (<T = Fallback>) eliminate required type verbosity.
  • We combined constraints with defaults: <T extends Bound = Default>.
  • We enforced the Parameter Ordering Rule (Required generics must precede optional generics).
  • We implemented production State Stores with default action parameters.

In Episode 32: Multiple Generic Parameters, we will explore managing multiple interacting generic type parameters in complex functions!

typescript - This article is part of a series.
Part 31: This Article