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
// 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' };Default Type Parameters on Functions
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[]Real-World Example: API Response Wrapper
A common pattern is an API response type where the error type defaults to a standard Error shape.
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
// 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.
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
// 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 }Referencing Earlier Type Parameters in Defaults
// 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
// 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 = numberWhen 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 |
// 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
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.
// 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