TypeScriptDefault Type Parameters

Default Type Parameters in TypeScript

Default type parameters let you specify a fallback type for a generic parameter — similar to default values for function arguments. When callers don't supply a type argument (and TypeScript can't infer one), the default kicks in. This dramatically reduces boilerplate at call sites while keeping the full power of generics available when needed.

Basic Syntax

TS
// T defaults to string if not specified
interface Container<T = string> {
  value: T;
  label: string;
}

// Without a type argument — uses the default (string)
const a: Container = { value: 'hello', label: 'greeting' };

// With an explicit type argument — overrides the default
const b: Container<number> = { value: 42, label: 'count' };
const c: Container<boolean> = { value: true, label: 'active' };
Note
Default type parameters work on interfaces, type aliases, classes, and functions — anywhere you declare a generic type parameter.
Default Type Parameters on Functions

TS
function createState<T = string>(initial: T) {
  let state = initial;

  return {
    get: (): T => state,
    set: (value: T) => { state = value; },
  };
}

// Without a type argument — T defaults to string
// BUT inference wins when a value is provided
const nameState = createState('Alice'); // T inferred as string (not the default)

// Where defaults matter: when you call without arguments
// TypeScript uses the default when it cannot infer
const emptyState = createState<number>(0); // T = number (explicit)

// Default matters most when inference isn't possible
function createEmptyArray<T = never>(): T[] {
  return [];
}

const arr1 = createEmptyArray();         // never[] — default
const arr2 = createEmptyArray<string>(); // string[]
Tip
Type inference always takes priority over the default. The default only applies when the type argument is omitted and cannot be inferred.
Real-World Example: API Response Wrapper

A common pattern is an API response type where the error type defaults to a standard Error shape.

TS
interface ApiError {
  code: string;
  message: string;
  details?: unknown;
}

// Data defaults to unknown, Error type defaults to ApiError
type Result<T = unknown, E = ApiError> =
  | { ok: true;  data: T }
  | { ok: false; error: E };

// Most callers just specify the data type
function parseUser(json: unknown): Result<User> {
  try {
    const user = json as User;
    return { ok: true, data: user };
  } catch {
    return { ok: false, error: { code: 'PARSE_ERROR', message: 'Invalid user data' } };
  }
}

// Advanced callers can override the error type too
type DomainResult<T> = Result<T, { type: 'NOT_FOUND' | 'FORBIDDEN'; message: string }>;

interface User { id: number; name: string; email: string }

const result = parseUser({ id: 1, name: 'Alice', email: 'alice@example.com' });
if (result.ok) {
  console.log(result.data.name); // string
} else {
  console.error(result.error.code); // string
}
Default Type Parameters on Classes

TS
// An event emitter where the payload defaults to void
class EventEmitter<TPayload = void> {
  private listeners = new Set<(payload: TPayload) => void>();

  on(listener: (payload: TPayload) => void): () => void {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  }

  emit(payload: TPayload): void {
    this.listeners.forEach(l => l(payload));
  }
}

// No-payload emitter (default void)
const clickEmitter = new EventEmitter();
clickEmitter.on(() => console.log('clicked!'));
clickEmitter.emit(); // ✅ no argument needed

// Typed-payload emitter
interface MouseEvent { x: number; y: number }
const mouseMoveEmitter = new EventEmitter<MouseEvent>();
mouseMoveEmitter.on(({ x, y }) => console.log(`${x}, ${y}`));
mouseMoveEmitter.emit({ x: 100, y: 200 });
Default + Constraint Together

You can combine extends (constraint) and = (default) on the same type parameter. The default must satisfy the constraint.

TS
interface Entity {
  id: string | number;
  createdAt: Date;
}

interface UserEntity extends Entity {
  name: string;
  email: string;
}

// T is constrained to Entity, with a default of UserEntity
class Repository<T extends Entity = UserEntity> {
  private store = new Map<T['id'], T>();

  save(entity: T): void {
    this.store.set(entity.id, entity);
  }

  findById(id: T['id']): T | undefined {
    return this.store.get(id);
  }

  findAll(): T[] {
    return Array.from(this.store.values());
  }
}

// Using the default (UserEntity)
const userRepo = new Repository();
userRepo.save({ id: '1', name: 'Alice', email: 'alice@example.com', createdAt: new Date() });

// Overriding with a concrete entity type
interface ProductEntity extends Entity {
  title: string;
  price: number;
}

const productRepo = new Repository<ProductEntity>();
productRepo.save({ id: 1, title: 'Keyboard', price: 99, createdAt: new Date() });
Multiple Default Type Parameters

TS
// A paginated response — both Page and Error have defaults
interface PaginatedResponse<
  TItem = Record<string, unknown>,
  TMeta = { total: number; page: number; perPage: number }
> {
  items: TItem[];
  meta: TMeta;
}

// Minimal usage — both defaults apply
const raw: PaginatedResponse = {
  items: [{ id: 1 }],
  meta: { total: 1, page: 1, perPage: 10 },
};

// Override just the item type
const users: PaginatedResponse<User> = {
  items: [{ id: 1, name: 'Alice', email: 'alice@example.com' }],
  meta: { total: 1, page: 1, perPage: 10 },
};

// Override both
interface CursorMeta {
  cursor: string | null;
  hasMore: boolean;
}
const cursored: PaginatedResponse<User, CursorMeta> = {
  items: [],
  meta: { cursor: null, hasMore: false },
};

interface User { id: number; name: string; email: string }
Note
When you have multiple type parameters with defaults, later parameters can reference earlier ones in their default expression.
Referencing Earlier Type Parameters in Defaults

TS
// TResult defaults to an array of TInput elements
type Transformer<TInput, TResult = TInput[]> = (input: TInput) => TResult;

// Default: number => number[]
const splitter: Transformer<number> = n => [n, n * 2, n * 3];

// Explicit: number => string
const stringify: Transformer<number, string> = n => n.toString();

// Another example: key type defaults to keyof T
function sortBy<T, K extends keyof T = keyof T>(
  items: T[],
  key: K
): T[] {
  return [...items].sort((a, b) => {
    const va = a[key], vb = b[key];
    return va < vb ? -1 : va > vb ? 1 : 0;
  });
}
Defaults in Third-Party Library Patterns

TS
// Mimicking React.useState signature
function useState<S = undefined>(): [S | undefined, (s: S) => void];
function useState<S>(initial: S): [S, (s: S) => void];
function useState<S>(initial?: S): [S | undefined, (s: S) => void] {
  let state = initial;
  const setState = (s: S) => { state = s; };
  return [state, setState];
}

// Mimicking React.createContext
function createContext<T = undefined>(defaultValue?: T): { Provider: unknown; Consumer: unknown } {
  return { Provider: null, Consumer: null };
}

// No type arg + no default value
const ctx1 = createContext();          // T = undefined
// With type arg
const ctx2 = createContext<string>(''); // T = string
// Inferred from value
const ctx3 = createContext(42);         // T = number
When to Use Default Type Parameters

Scenario

Use default?

Reason

Generic utility used 80% with one type

Yes

Reduces boilerplate at common call sites

Library type that callers usually ignore

Yes

Keeps API ergonomic, power users can still override

All callers always specify the type

No

Default adds no value

The default would be misleading

No

Explicit is clearer

Error / metadata type in Result/Either

Yes

Standard Error is the right default

Default Type Parameters vs Function Default Arguments

It is easy to conflate default type parameters with default function arguments — but they operate at completely different levels.

Feature

Default type param

Default argument

Level

Compile time (types only)

Runtime (values)

Syntax

function f<T = string>()

function f(x = "hello")

Erased by compiler

Yes

No — present in JS output

Inference overrides it

Yes

No — always used if arg is undefined

Works with interfaces/type aliases

Yes

N/A

TS
// Default TYPE parameter (compile-time)
function wrap<T = string>(value: T): { value: T } {
  return { value };
}

// Default ARGUMENT (runtime)
function greet(name: string = 'World'): string {
  return `Hello, ${name}!`;
}

// Combine both
function createField<T = string>(
  label: string,
  defaultValue: T = '' as unknown as T
): { label: string; value: T } {
  return { label, value: defaultValue };
}

const nameField = createField('Name');            // { label: string; value: string }
const ageField  = createField<number>('Age', 0); // { label: string; value: number }
Practical: Generic Logger with Default Level

TS
type LogLevel = 'debug' | 'info' | 'warn' | 'error';

interface LogEntry<TData = Record<string, unknown>> {
  level: LogLevel;
  message: string;
  data?: TData;
  timestamp: Date;
}

class Logger<TContext = Record<string, unknown>> {
  constructor(private context: TContext) {}

  log<TData = Record<string, unknown>>(
    level: LogLevel,
    message: string,
    data?: TData
  ): LogEntry<TData> {
    const entry: LogEntry<TData> = {
      level,
      message,
      data,
      timestamp: new Date(),
    };
    console.log(`[${level.toUpperCase()}] ${message}`, data ?? '');
    return entry;
  }

  info(message: string): LogEntry { return this.log('info', message); }
  warn(message: string): LogEntry { return this.log('warn', message); }
  error(message: string): LogEntry { return this.log('error', message); }
}

// Using defaults — no type arguments needed
const logger = new Logger({ service: 'api' });
logger.info('Server started');
logger.log('error', 'DB connection failed', { host: 'localhost', port: 5432 });
// data is typed as { host: string; port: number }
Default Type Parameters in React

React's own type definitions use default type parameters extensively. Understanding the pattern helps you read and write typed React components.

TS
// Simplified React.FC typing uses defaults
// interface FC<P = {}> { (props: P): ReactElement | null }

// A generic list component with a default item type
interface ListProps<T = string> {
  items: T[];
  renderItem?: (item: T, index: number) => string;
  keyExtractor?: (item: T) => string;
}

// When T is not provided, items is string[]
function createListConfig<T = string>(props: ListProps<T>): ListProps<T> {
  return {
    keyExtractor: (item) => String(item),
    ...props,
  };
}

// Uses the default — items is string[]
const config1 = createListConfig({ items: ['Alice', 'Bob', 'Charlie'] });

// Override — items is { id: number; name: string }[]
const config2 = createListConfig({
  items: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }],
  renderItem: (item) => item.name,
  keyExtractor: (item) => String(item.id),
});
Quick Reference
  • Syntax: type Foo<T = DefaultType> — works on interfaces, type aliases, classes, functions

  • Inference wins: if TypeScript can infer T, the default is not used

  • Default applies only when T is omitted and cannot be inferred

  • Combine constraint and default: <T extends Base = ConcreteBase>

  • Later params can reference earlier params in their default: <T, U = T[]>

  • Keep defaults sensible — a confusing default is worse than no default

  • Default type params are a compile-time feature — they have no effect on the runtime output

Success
Default type parameters make generic APIs ergonomic for the common case while staying fully flexible for advanced use. Next: common generic patterns — real-world idioms you will encounter (and write) throughout TypeScript codebases.