Generic Defaults & Inference
Reviewed & published by Brayan K
Generic defaults give a type parameter a fallback (<T = string>) when none is provided, while type inference lets TypeScript figure out a generic's type from the arguments you pass — so you rarely write the angle brackets yourself.
Part of the free TypeScript course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Give a type parameter a default with <T = string>
- Let TypeScript infer type arguments from your values
- Know exactly when you must annotate a type yourself
- Use const type parameters to keep literal types
- Understand widening and how to prevent it
- Avoid the most common inference pitfalls
💡 Real-World Analogy
Think of a coffee order. Inference is the barista guessing your milk from what you usually get — you don't say it, they just know. A default is the house standard when you don't specify ("regular oat milk unless you say otherwise"). And a const type parameter is the barista writing down your exact order word-for-word ("extra-hot, half-shot") instead of rounding it to "a coffee" — the precise details are kept.
1. Default Type Parameters
You can give a generic a default with <T = SomeType>. If the caller neither supplies T nor gives the compiler something to infer it from, T falls back to that default. Defaults shine on generic interfaces and classes — like ApiResponse<TData = unknown> — so simple callers don't have to spell out a type argument.
// T = string sets a DEFAULT for the type parameter:
function createBox<T = string>(value?: T): { value: T | undefined } {
return { value };
}
createBox("hello"); // T inferred as string -> { value: string }
createBox<number>(); // you can still pick T explicitly -> { value: number | undefined }
// Defaults are common on generic interfaces/classes so callers can skip params:
interface ApiResponse<TData = unknown> {
ok: boolean;
data: TData;
}
const r: ApiResponse = { ok: true, data: 123 }; // TData defaults to unknown
const u: ApiResponse<string> = { ok: true, data: "x" }; // or specify it// A DEFAULT type parameter is like a default function argument, but for types.
// In .ts: function createBox<T = string>(value: T): { value: T } { ... }
// If you don't supply T (and TS can't infer it), it falls back to string.
// At runtime there are no type params, so here is the VALUE-level equivalent:
// a default parameter that kicks in when no argument is passed.
function createBox(value = "empty") {
return { value };
}
console.log(createBox("hello")); // { value: 'hello' } (you supplied a value)
console.log(createBox()); // { value: 'empty' } (default kicked in)
console.log(createBox(42)); // { value: 42 } (a number is fine too)2. How Inference Works (and When to Annotate)
Most of the time you never write <...> — TypeScript infers the type argument from the value you pass. identity("hi") infers T = string; first([true, false]) infers the element type. You must annotate only when there's nothing to infer from — for example a function that returns T[] but takes no T argument.
// TS infers type arguments from the values you pass — no <...> needed:
function identity<T>(x: T): T { return x; }
identity("hi"); // T = string, inferred
identity(7); // T = number, inferred
function first<T>(arr: T[]): T | undefined { return arr[0]; }
first(["a", "b"]); // T = string -> returns string | undefined
// You MUST annotate when there's nothing to infer FROM:
function make<T>(): T[] { return []; }
const xs = make<number>(); // no argument -> you supply T explicitly
// const ys = make(); // ❌ T is 'unknown' — nothing to infer from// INFERENCE: TS reads T from the ARGUMENT you pass, so you rarely write <...>.
// identity("hi") infers T=string; identity(7) infers T=number. At runtime the
// function is generic-free — the value just flows through unchanged.
function identity(x) {
return x; // whatever comes in, comes out — TS keeps its exact type
}
console.log(identity("hi")); // hi (T inferred as string)
console.log(identity(7)); // 7 (T inferred as number)
console.log(identity([1, 2, 3])); // [ 1, 2, 3 ] (T inferred as number[])
// first(arr) infers the element type FROM the array:
function first(arr) {
return arr[0];
}
console.log(first(["a", "b"])); // a (return type inferred as string)
console.log(first([true, false])); // true (return type inferred as boolean)3. const Type Parameters & Widening
By default, inference widens literals: pass ["red", "green"] and you get string[], losing the exact values. A const type parameter (<const T>, TypeScript 5.0) tells the compiler to keep the narrowest literal type — the precise tuple — without scattering as const across every call.
// WITHOUT const: inference WIDENS literals to general types:
function loosen<T>(arr: T[]): T[] { return arr; }
const a = loosen(["red", "green", "blue"]); // a: string[]
// WITH a const type parameter (TS 5.0): inference KEEPS the literals:
function preserve<const T>(arr: readonly T[]): readonly T[] { return arr; }
const b = preserve(["red", "green", "blue"]);
// b: readonly ["red", "green", "blue"] — exact literal tuple, no widening
// Before const type params you had to write 'as const' at the call site:
const c = loosen(["red", "green"] as const); // readonly ["red", "green"]// const type parameters (TS 5.0): <const T> makes TS infer the NARROWEST
// (literal) type instead of widening. The RUNTIME value is identical — only the
// inferred TYPE differs. Compare what the data 'is' in each case.
// WIDENING (no const): TS would infer string[] — element identity lost in the type.
function loosen(arr) { return arr; }
// PRESERVING (const T): TS would infer the exact readonly tuple
// ['red','green','blue'] — each element keeps its literal type.
function preserve(arr) { return arr; }
const a = loosen(["red", "green", "blue"]);
const b = preserve(["red", "green", "blue"]);
console.log(a); // [ 'red', 'green', 'blue' ]
console.log(b); // [ 'red', 'green', 'blue' ]
console.log(b[0]); // red (with const T, its type is the literal 'red')
console.log(b.length); // 3🎯 Your Turn
Mirror a generic default using a default parameter: greet() with no argument should fall back to "guest". Fill in the two blanks marked ___, then run it.
// 🎯 YOUR TURN — replace each ___ then press "Try it Yourself".
// This mirrors a generic default: function greet<T = string>(name: T = "guest")
// Use a DEFAULT parameter so calling with no argument falls back to "guest".
function greet(name = ___) { // 👉 the default string "guest"
return "Hello, " + name;
}
console.log(greet("Ada")); // Hello, Ada
console.log(___); // 👉 call greet() with NO argument -> Hello, guest
// ✅ Expected output:
// Hello, Ada
// Hello, guestCommon Errors (and the fix)
- ❌ "Type 'unknown' is not assignable..." — a generic had no argument to infer from. ✅ Annotate the call: make<number>(), or restructure so T can be inferred.
- ❌ Inferred type is too wide (string instead of "red") — inference widened literals. ✅ Use a <const T> type parameter, or as const at the call site.
- ❌ "Type parameter 'T' has a circular default" — a default referenced itself. ✅ Make the default a concrete type, not one that depends on T in a loop.
- ❌ Default placed before a non-default parameter — <T = string, U> is invalid. ✅ Defaulted type parameters must come after required ones, just like function args.
Pro Tips
- 💡 Let inference do the work. Writing explicit type arguments everywhere is usually a sign the API could be designed to infer them.
- 💡 Add a sensible default (often unknown) to generic interfaces so simple consumers can skip the type argument.
- 💡 Reach for <const T> in builder/config helpers where you want literal keys and values preserved.
- 💡 Constrain and default together: <T extends object = {}> gives both a bound and a fallback.
📚 Keep Learning
- 📖 TypeScript Handbook — Generics
- 📖 TS 5.0 Release Notes — const Type Parameters
- 📖 Handbook — Type Inference
Mini-Challenge: A wrap() Helper
No blanks this time — just a brief and a starting outline. Build the helper yourself, run it, and check your output against the example in the comments.
// 🎯 MINI-CHALLENGE: a wrap() helper that shows inference + a default.
// In .ts: function wrap<T = null>(value: T = null): { ok: true; value: T }
// 1. wrap(value) returns { ok: true, value } and defaults value to null.
// 2. Call wrap("data"), wrap(99), and wrap() with no argument.
// 3. Log all three results.
//
// ✅ Expected output:
// { ok: true, value: 'data' }
// { ok: true, value: 99 }
// { ok: true, value: null }
// your code here🎉 Lesson Complete
- ✅ <T = string> gives a type parameter a default fallback
- ✅ TypeScript infers type arguments from the values you pass
- ✅ You only annotate when there's nothing to infer from
- ✅ Inference widens literals to general types by default
- ✅ <const T> (TS 5.0) keeps the narrow literal types without as const
Practice quiz
What is a default type parameter?
- A runtime default argument
- An interface
- A fallback type for a generic, written like <T = string>
- A const assertion
Answer: A fallback type for a generic, written like <T = string>. <T = string> is the type-level version of a default function parameter.
When does the default in '<T = string>' get used?
- When the caller does not supply T and TypeScript cannot infer it from arguments
- Always
- Only with classes
- Only in strict mode
Answer: When the caller does not supply T and TypeScript cannot infer it from arguments. If T can be inferred from an argument, the inferred type wins; the default applies only otherwise.
How does TypeScript usually figure out a generic's type argument?
- You must always write <...>
- From the tsconfig
- Randomly
- It infers it from the values you pass at the call site
Answer: It infers it from the values you pass at the call site. identity("hi") infers T = string and identity(7) infers T = number, so you rarely write <...>.
When MUST you annotate a type argument yourself?
- Always
- When there is nothing to infer from (e.g. function make<T>(): T[])
- Only for strings
- Never
Answer: When there is nothing to infer from (e.g. function make<T>(): T[]). With no argument of type T, the compiler has nothing to read T from, so you write make<number>().
If you call 'function make<T>(): T[]' with no annotation, what is T?
- unknown
- string
- never
- any
Answer: unknown. With nothing to infer from and no default, T defaults to unknown.
By default, inference of '["red", "green"]' produces which type?
- readonly ["red", "green"]
- ("red" | "green")[] always
- string[]
- never[]
Answer: string[]. Inference widens literals, so you get string[] and lose the exact values.
What does a const type parameter '<const T>' (TS 5.0) do?
- Freezes the array at runtime
- Tells the compiler to infer the narrowest literal type instead of widening
- Makes T a constant value
- Requires 'as const' everywhere
Answer: Tells the compiler to infer the narrowest literal type instead of widening. <const T> keeps literal/tuple types, a cleaner alternative to scattering 'as const'.
Does '<const T>' change the runtime value of the array?
- Yes, it freezes it
- Yes, it copies it
- Only in development
- No — it only affects the inferred type
Answer: No — it only affects the inferred type. It changes only the type; the runtime array is exactly the same array.
Where must a defaulted type parameter appear relative to non-defaulted ones?
- Before them
- After them
- Anywhere
- Only alone
Answer: After them. <T = string, U> is invalid; defaulted type parameters must come after required ones.
On a generic interface, why add a default like 'ApiResponse<TData = unknown>'?
- For speed
- To enable inference of TData
- So simple callers can omit the type argument
- It is required syntax
Answer: So simple callers can omit the type argument. A default lets consumers write ApiResponse without spelling out the type argument.
Continue this course
- Previous: DOM & Event Types
- Next: Function Types, this, Parameters & ReturnType — Type functions precisely and extract their parameter and return types
- Quick reference: TypeScript cheat sheet
Frequently asked questions
What is a default type parameter?
It's a fallback type for a generic, written like <T = string>. If a caller doesn't supply T and TypeScript can't infer it from the arguments, T becomes the default. It's the type-level version of a default function parameter and is especially handy on generic interfaces and classes so callers can omit type arguments.
How does TypeScript infer type arguments?
It reads them from the values you pass. For identity<T>(x: T), calling identity('hi') infers T = string; calling identity(7) infers T = number. This is why you rarely write the <...> explicitly — inference fills it in from the call site, propagating through return types too.
When do I HAVE to annotate a type argument myself?
When there's nothing to infer from. If a generic function takes no arguments of type T (for example 'function make<T>(): T[]'), TypeScript has no value to read T from, so you must write make<number>(). The same applies when the inferred type would be too wide and you want a specific one.
What are const type parameters?
Introduced in TypeScript 5.0, writing <const T> tells the compiler to infer the narrowest possible (literal) type for T instead of widening it. So preserve(['a','b']) can infer the exact tuple readonly ['a','b'] rather than string[]. It's a cleaner alternative to sprinkling 'as const' at every call site.