Type parameter defaults look like a small convenience. type Result<T, E = Error> lets most callers write Result<number> and only the unusual ones spell out the error type. The trouble starts when defaults meet inference in a generic function. There, a default can silently replace a type TypeScript would otherwise have inferred, and the order of your type parameters decides whether callers can use your API without fighting it.
Where defaults work without surprises
On type aliases, interfaces and classes, a default behaves like an optional function parameter. Nothing is inferred for a type annotation, so either the caller writes the argument or the default applies.
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
type ValidationError = { field: string; message: string };
function parsePort(raw: string): Result<number> {
const n = Number(raw);
return Number.isInteger(n) && n > 0 && n < 65536
? { ok: true, value: n }
: { ok: false, error: new Error(`invalid port: ${raw}`) };
}
function parseEmail(raw: string): Result<string, ValidationError> {
return raw.includes('@')
? { ok: true, value: raw.toLowerCase() }
: { ok: false, error: { field: 'email', message: 'missing @' } };
}
const port = parsePort('80800');
if (!port.ok) console.log(port.error.message);
const email = parseEmail('ada.example.com');
if (!email.ok) console.log(email.error.field, email.error.message);This compiles with --strict and prints:
invalid port: 80800
email missing @The ordering choice is the obvious one. T is the thing every caller cares about, so it’s required and comes first. E is usually Error, so it gets a default and goes second. TypeScript enforces part of this: a required type parameter can’t follow one with a default.
type A<T = string, U> = [T, U];
type B<T = U, U = string> = [T, U];
type C<T extends object = string> = T;
type Pick2<T, K extends keyof T = keyof T> = { [P in K]: T[P] };
type U1 = Pick2<{ a: 1; b: 2 }>;defaults.ts(1,20): error TS2706: Required type parameters may not follow optional type parameters.
defaults.ts(2,12): error TS2744: Type parameter defaults can only reference previously declared type parameters.
defaults.ts(3,27): error TS2344: Type 'string' does not satisfy the constraint 'object'.Three rules, then. Defaulted parameters go after required ones. A default can mention earlier parameters but not later ones, which is why Pick2’s K = keyof T is fine and B’s T = U isn’t. And the default must itself satisfy the parameter’s constraint, since TypeScript has to be able to use it in every place the parameter is used.
Defaults in generic functions
In a function call, TypeScript tries to infer each type parameter from the arguments. The default only matters when inference finds nothing to go on. A small cache factory shows both cases:
function createCache<V = string>(seed?: Record<string, V>) {
const map = new Map<string, V>(Object.entries(seed ?? {}));
return { get: (k: string) => map.get(k), set: (k: string, v: V) => void map.set(k, v) };
}
const names = createCache();
names.set('a', 42);
const counts = createCache({ hits: 0 });
counts.set('misses', 'none');cache.ts(6,16): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
cache.ts(8,22): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.createCache() has no argument to infer from, so V takes its default, string. createCache({ hits: 0 }) infers V as number from the seed, and the default is ignored. Without the default, the first call would have produced a cache of unknown, which accepts anything and returns values you can’t use without a cast. That’s the main job of a default in a function: choosing a useful type for the no-information case.
The partial inference trap
Now the case interviewers like. You’re writing a typed getJson helper. The response type can’t be inferred from anything (it comes over the network), so callers must write it. The query object can be inferred. A first attempt:
type User = { id: number; name: string };
function getJson<TResponse, TQuery extends Record<string, string | number>>(
path: string,
query: TQuery,
): Promise<TResponse> {
const qs = new URLSearchParams(Object.entries(query).map(([k, v]) => [k, String(v)]));
return fetch(`${path}?${qs}`).then((r) => r.json() as Promise<TResponse>);
}
const a = getJson('/users', { id: 7 });
const b = getJson<User>('/users', { id: 7 });
const s: string = getJson("/users", { id: 7 });get-json.ts(12,19): error TS2558: Expected 2 type arguments, but got 1.
get-json.ts(13,7): error TS2322: Type 'Promise<unknown>' is not assignable to type 'string'.Two problems. The call that leaves out type arguments compiles, but TResponse has nothing to infer from, so it becomes unknown (the last line is there only to make the compiler print the type). And the call that supplies just User is rejected outright, because TypeScript doesn’t do partial type argument inference. Once you write any type argument, you must write all the required ones, and inference is switched off for the whole call.
The tempting fix is to give TQuery a default:
type User = { id: number; name: string };
function getJson<TResponse, TQuery extends Record<string, string | number> = Record<string, string>>(
path: string,
query: TQuery,
): Promise<TResponse> {
return Promise.resolve(JSON.parse('{"id":7,"name":"Ada"}') as TResponse);
}
const b = getJson<User>('/users', { id: 7 });
const c = getJson<User>('/users', { id: '7' });get-json-default.ts(10,37): error TS2322: Type 'number' is not assignable to type 'string'.Now getJson<User> compiles in principle, but TQuery isn’t inferred from { id: 7 }. Because you wrote one type argument, TQuery takes its default, Record<string, string>, and the perfectly reasonable numeric id is rejected. This is the sentence to remember: a default doesn’t fill in “whatever inference would have found”, it replaces inference. People who expect partial inference often conclude TypeScript is buggy here; it’s behaving as designed.
Ordering for callers, not for the implementer
The usable rule for functions is to order type parameters by how callers supply them:
- Parameters that callers must write explicitly, because no argument mentions them, go first.
- Parameters inferred from arguments go after them.
- If a call might write the first group but not the second, the second group needs defaults that are genuinely correct, not merely convenient, because those defaults are what callers will get.
When a parameter is explicit-only and others need inference, ordering alone can’t solve it. The standard workaround is to split the call in two, so each call has its own type parameter list:
type User = { id: number; name: string };
type Query = Record<string, string | number>;
function getJson<TResponse>() {
return <TQuery extends Query>(path: string, query: TQuery): Promise<TResponse> => {
const qs = new URLSearchParams(Object.entries(query).map(([k, v]) => [k, String(v)]));
return Promise.resolve(JSON.parse(`{"id":7,"name":"Ada","qs":"${qs}"}`) as TResponse);
};
}
const user = await getJson<User>()('/users', { id: 7, include: 'roles' });
console.log(user.name, user);That compiles cleanly and prints Ada { id: 7, name: 'Ada', qs: 'id=7&include=roles' } (the stub stands in for fetch so the example runs offline). The first call fixes TResponse; the second infers TQuery freely. You’ll see this curried shape in libraries for exactly this reason.
Often the simpler design is to ask whether TQuery needs to be generic at all. If the function only reads the query to build a string, typing the parameter as Query directly removes the second type parameter, and getJson<User>('/users', { id: 7 }) just works. Generic parameters earn their place when they connect an input type to an output type. A type parameter used once is usually a sign you wanted the constraint itself.
One more caution: as TResponse on parsed JSON is a claim, not a check. The generic makes call sites tidy, but if the server sends { "userId": 7 }, nothing stops it. Pair helpers like this with runtime validation at the boundary.
Interview exercise: a typed event emitter
You maintain createEmitter<Events extends Record<string, unknown[]>, Ctx = void>(ctx?: Ctx). Teams call it as createEmitter<{ saved: [id: string] }>() and as createEmitter({ tenant: 'acme' }) hoping to get a typed context. The second group complains that on('saved', ...) accepts any event name. Explain why, and propose a better signature.
Answer
In the second call nothing mentions Events in the arguments, and there’s no default, so TypeScript infers it from its constraint: Events becomes Record<string, unknown[]>, which allows every event name with any arguments. The first group has the opposite problem waiting for them: if they ever want a context too, createEmitter<{ saved: [id: string] }>({ tenant: 'acme' }) gets Ctx = void from the default rather than inferring it, and the context argument fails to type-check.
The two type parameters are supplied in different ways (one always explicit, one naturally inferred), so they shouldn’t share a call. I’d make it createEmitter<Events>() returning a function that takes the context: createEmitter<Events>()({ tenant: 'acme' }), or I’d move the context to a separate withContext(ctx) method on the returned emitter. If keeping one call is a hard requirement, I’d give Events a default of Record<never, never> so forgetting the event map makes every on() call fail loudly instead of accepting everything.