11.6 Default Type Parameters
Right, default type parameters. This is where we stop merely using generics and start designing with them. It’s the feature that lets you build truly flexible and ergonomic APIs. The core idea is brilliantly simple: you can specify a default type for a generic parameter, just like you can specify a default value for a function parameter.
Think of it as a contingency plan for your types. If the user of your function or class is kind enough to specify a type, you’ll use that. If they don’t, and just use empty angle brackets <> or, even better, no brackets at all, you gracefully fall back to the default. It makes your generics feel less demanding and more helpful.
Here’s the most basic, textbook example you’ll see everywhere:
function createArray<T = number>(length: number, value: T): T[] {
return Array(length).fill(value);
}
// The user doesn't specify T. TypeScript says "Okay, default to number".
const numberArray = createArray(3, 42); // T is number, type is number[]
// The user *does* specify T, so the default is ignored.
const stringArray = createArray<string>(3, "hello"); // T is string, type is string[]
It’s neat, but it’s a bit contrived. Let’s talk about where this really shines.
The Power of Sane Defaults
The real utility of default type parameters isn’t in standalone functions; it’s in complex object structures, classes, and interfaces where specifying every single generic type would be a tedious nightmare. You use defaults to hide complexity until the user actually needs to change it.
Consider a generic ApiResponse interface for a backend API. Maybe 95% of your endpoints return a payload that’s a JSON object. You can make that the default.
interface ApiResponse<TPayload = Record<string, unknown>> {
success: boolean;
statusCode: number;
payload: TPayload;
// ...other metadata fields
}
// The common case: no type needed! It defaults to a generic object.
const userResponse: ApiResponse = await fetchUser();
// userResponse.payload is of type Record<string, unknown>
// The special case: for the /config endpoint, we know the precise shape.
const configResponse: ApiResponse<{ theme: 'light' | 'dark'; notifications: boolean }> = await fetchConfig();
// configResponse.payload is now { theme: 'light' | 'dark'; notifications: boolean }
Without the default, you’d be forced to write ApiResponse<Record<string, unknown>> every single time for the common case, which is just noisy. The default makes the common path elegant.
Order Matters (A Lot)
Here’s the first “gotcha,” and it’s a direct parallel to default function parameters in JavaScript: optional type parameters must come after required ones. The TypeScript compiler isn’t psychic. It assigns types to parameters positionally.
// This is correct. Required first (T), default second (U).
function goodFunction<T, U = number>(arg1: T, arg2: U): [T, U] {
return [arg1, arg2];
}
// This is a syntax error. You can't have a required parameter after an optional one.
// function badFunction<T = string, U>(arg1: T, arg2: U) {} // Error!
Think of it like this: if you call goodFunction("hello", 42), how could TypeScript possibly know that "hello" is for T and 42 is for U if U had a default and T didn’t? The order resolves the ambiguity. It’s a constraint, but a logical one.
Defaults and Constraints: A Delicate Dance
You can, and often should, combine default types with constraints (extends). This is how you build a truly robust API. The constraint defines what’s allowed, and the default defines what’s used if nothing is specified.
Let’s design a Store class that caches items. We want to constrain the key to be a string or number, defaulting to string because that’s most common.
class Store<T, K extends string | number = string> {
private data = new Map<K, T>();
set(key: K, value: T): void {
this.data.set(key, value);
}
get(key: K): T | undefined {
return this.data.get(key);
}
}
// Defaults are magical. K defaults to string, T is inferred as number from .set().
const stringKeyStore = new Store<number>();
stringKeyStore.set('user_id', 42); // Works great.
// But we can override the default for K when we need a numeric key.
const numericKeyStore = new Store<boolean, number>();
numericKeyStore.set(12345, true); // Also works.
Notice the order: the required T comes first, and the constrained-with-default K comes second. This is a classic pattern.
The Pitfall: Relying on Inference with Defaults
Be careful when you have multiple type parameters and rely on inference. The compiler will try to infer everything it can from usage, which can lead to the default being ignored in ways you might not expect.
function trickyFn<T = string, U = number>(a: T, b: U): [T, U] {
return [a, b];
}
// What happens here?
const result = trickyFn(100, "hello");
You might expect T to default to string and U to default to number. But that’s not what happens. The compiler is smarter than that. It infers T from the first argument (100, so T is number) and U from the second argument ("hello", so U is string). The defaults are completely bypassed because inference filled in the blanks. This is almost always what you want, but it’s crucial to understand that defaults are a fallback, not a mandate. They only kick in when TypeScript has absolutely no other information to go on.