Skip to content

Generic Functions & Constraints

A type parameter is inferred, not usually written

Section titled “A type parameter is inferred, not usually written”

Most of the time you never write the type argument — inference fills it in from the value you pass:

function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const a = first([1, 2, 3]);
// ^? number | undefined — T inferred as number
const b = first(["x", "y"]);
// ^? string | undefined — T inferred as string

You can pass it explicitly (first<number>([1, 2, 3])), but you rarely need to. The type parameter flows from the call site.

An unconstrained T can be anything, so you can’t assume it has any properties. When your function needs T to have some shape, constrain it with extends:

// @expect-error TS2339 TS2345
// ❌ without a constraint, T might not have .length
function longestLoose<T>(a: T, b: T) {
return a.length > b.length ? a : b; // Error: Property 'length' does not exist on type 'T'
}
// ✅ constrain T to things that have a numeric length
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
longest([1, 2], [1, 2, 3]); // ✅ arrays have length → returns number[]
longest("ab", "abc"); // ✅ strings have length → returns string
longest(1, 2); // ❌ number has no length

Read T extends { length: number } as “T must be assignable to { length: number }” — i.e. T is some subtype that has at least a numeric length. Crucially, the return type is still T, so you keep the specific type (number[], string) rather than widening to the constraint.

A constraint can reference another type parameter. The classic example is a type-safe get:

// @expect-error TS2345
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Ada", age: 36 };
const label = getProp(user, "name");
// ^? string
const age = getProp(user, "age");
// ^? number
getProp(user, "email"); // ❌ "email" is not a key of user

K extends keyof T says the key must be one of T’s actual keys, and the return type T[K] is the type of that specific property. This is the backbone of typed object utilities.

Like default function arguments, a type parameter can have a default that’s used when it can’t be inferred and isn’t supplied:

interface Container<T = string> {
value: T;
}
const c1: Container = { value: "hi" }; // T defaults to string
const c2: Container<number> = { value: 1 }; // T explicitly number

Defaults are common on generic classes and interfaces where a sensible fallback exists.

Don’t over-generalize — but “used once” needs a nuance

Section titled “Don’t over-generalize — but “used once” needs a nuance”

Generics are a tool, not a goal. A type parameter that appears only once in the entire signature, and nowhere else is usually a mistake — it’s not linking anything, so it might as well be a concrete type or unknown:

// pointless generic: T is used once, links nothing
function logLoose<T>(x: T): void { console.log(x); }
// just write:
function log(x: unknown): void { console.log(x); }

But “used once” needs to be read carefully. The rule isn’t “counted in the parameter list” — it’s “does this type parameter connect an input to an output at all?” A T that appears exactly once among the parameters, but also shows up in the return type, is still linking two positions, and that makes it a real generic, not a smell:

// @expect-error TS2339
interface Tagged<T> { value: T }
// T appears once in the parameter list — but it ALSO appears in the return
// type, tying the argument to the result. That IS a link, so this is fine.
function tag<T>(value: T): Tagged<T> {
return { value };
}
const t = tag(42);
// ^? Tagged<number>
t.value.toUpperCase(); // ❌ number has no toUpperCase — the link still catches real bugs

Compare the two: logLoose<T>(x: T): void has T in exactly one position, period — the return type is void, so nothing downstream is ever checked against it. tag<T>(value: T): Tagged<T> also has T written once among the parameters, but T reappears in the return type, so a caller who does something wrong with t.value still gets caught. Count links, not raw appearances.

Inference through a generic call widens literals the same way let does: first(["a", "b"]) infers T as string, not "a" | "b". Callers used to fix that with as const at every call site; a const modifier on the type parameter asks for literal inference by default:

function tuple<const T extends readonly unknown[]>(...items: T): T {
return items;
}
const t = tuple("a", 1, true);
// ^? readonly ["a", 1, true] — without const: [string, number, boolean], same tuple shape, literals widened

The constraint usually needs readonly (the inferred literal is a readonly tuple or object). Use it for APIs that key off literal values — route tables, enum-like configs, builders — and leave ordinary generics alone.

const T still widens — it isn’t as const retroactively

Section titled “const T still widens — it isn’t as const retroactively”

const on a type parameter only changes how that call’s own arguments get inferred. It cannot reach back and un-widen a value that was already widened somewhere else before it arrived:

function firstOf<const T>(items: T[]): T {
return items[0];
}
const arr = ["a", "b"]; // arr is already string[] — an ordinary mutable array, not a tuple
const f = firstOf(arr);
// ^? string — NOT "a" | "b": arr was widened at its own declaration, long before firstOf saw it

const on tuple’s T worked because the rest parameter (...items: T) captures each argument as its own literal at the call site, before anything widens it. firstOf’s T is inferred from arr’s element type, and arr was already string[] by the time it got there — there is no literal left for const to preserve. const T preserves literals it can see arriving; it doesn’t reverse widening that already happened upstream.

Both let one name serve several call shapes, but they solve different problems. A generic is right when the same formula applies uniformly across every type — T in, T (or something built from T) out. An overload set is right when you have a small, fixed number of specific (input, output) pairings that don’t reduce to one formula.

Overloads can also produce a clearer error than a poorly-chosen generic. Compare two versions of a function that must receive two arguments of the same type:

// @expect-error TS2345
function both<T>(a: T, b: T): T { return a; }
const y = both(1, "two"); // the first argument fixes T to the literal 1, not number
// @expect-error TS2769
function bothOverload(a: string, b: string): string;
function bothOverload(a: number, b: number): number;
function bothOverload(a: unknown, b: unknown): unknown { return a; }
bothOverload(1, "two");

A generic can give a worse error than an overload

// @expect-error TS2345 TS2769
function both<T>(a: T, b: T): T { return a; }
const y = both(1, "two");
// TS2345: Argument of type '"two"' is not assignable to parameter of type '1'.
// -- confusing: "type '1'"? T was inferred from the literal 1, not widened to number,
// because nothing in this signature asks for the number the literal belongs to.
function bothOverload(a: string, b: string): string;
function bothOverload(a: number, b: number): number;
function bothOverload(a: unknown, b: unknown): unknown { return a; }
bothOverload(1, "two");
// TS2769: No overload matches this call. The last overload gave the following error.
// Argument of type 'string' is not assignable to parameter of type 'number'.
// -- clearer: it names the actual types, string and number, not a literal.
Open in TS Playground

Click Check types above: both’s error reads Argument of type '"two"' is not assignable to parameter of type '1' — a reader has to stop and wonder why a parameter’s type is the literal 1 rather than number. It happens because T was pinned to the literal type of the first argument before the second was even checked. bothOverload’s error, No overload matches this call. ... Argument of type 'string' is not assignable to parameter of type 'number', names the real types involved. Neither message is wrong, but the overload’s is easier for a reader to act on immediately — this is the case the “generics are always better than overloads” instinct gets backwards.

The reverse is common too: once you have more than two or three pairings, or the relationship between input and output is uniform (an array in, an array of the same element type out), an overload set turns into unmaintainable repetition and a generic is clearly right — every function you’ve seen so far in this lesson (first, longest, getProp) is exactly that case.

A type parameter is a variable whose “value” is a type, solved once per call from the positions it’s written into. Ask two questions about any T you read: where does it come from (which argument position feeds it), and where does it go (which other positions — parameters, return type — does it constrain once it’s fixed)? If the answer to “where does it go” is “nowhere else,” the type parameter is decorative. If a constraint doesn’t actually get used inside the body, it’s also worth asking whether the constraint is doing anything beyond documentation.

ChoiceBenefitCost
Unconstrained TWorks for literally any typeCan’t assume any shape — no property access, no arithmetic
T extends { shape }Lets the body use the shape while keeping the specific type in the returnEvery caller’s type must satisfy the constraint, which can reject otherwise-valid values that just don’t have that exact shape
const TPreserves literal/tuple shape without as const at every call siteOnly helps when the literal is still visible to that call — can’t undo widening that already happened
Overload setClear, per-case error messages; documents exact valid pairingsDoesn’t scale — each new valid combination is a new signature to write and maintain
  • A type parameter used once in the parameter list is always a smell. Only if it’s used once in the whole signature. If it also appears in the return type, it’s linking input to output — that’s a real generic.
  • A generic function’s error messages are always clearer than an overload’s. Not necessarily — a generic can pin a type parameter to a narrow literal type early, producing a confusing “not assignable to type ‘1’“-style message that an equivalent overload set would state in plain, named types.
  • const on a type parameter reaches back and un-widens whatever you pass, no matter how it got there. It only captures literals that are still literals at the call site. A value already widened by its own let declaration, or read out of an already-widened array, arrives at the generic call pre-widened, and const has nothing left to preserve.

A generic that exists purely to wrap a cast is a common AI-generated shape — it looks like type safety, but the type parameter is unconstrained and never checked against anything real:

  • Look for a generic helper whose body is return value as T (or as unknown as T) with T unconstrained. This compiles for any T the caller names, exactly like a plain as cast — the generic adds nothing but the appearance of having “typed” the value.
  • Check whether the function ever narrows, validates, or otherwise inspects the value before returning it as T. If it doesn’t, the function’s real type is (value: unknown) => any, no matter what its signature claims.
  • A chain of two such helpers (one parses to unknown, the next casts unknown to T) has the exact same problem as a single cast, just spread across two calls that each look individually reasonable.

🐛 Spot the bug

interface Config { retries: number; timeout: number }
function assertType<T>(value: unknown): T {
return value as T;
}
function loadConfig(raw: unknown): Config {
return assertType<Config>(raw);
}
const cfg = loadConfig({ retries: 3 });
console.log(cfg.timeout.toFixed(0));
Show the answer

This compiles with zero errors. assertType<T> is unconstrained and its body is a bare value as T — it will “successfully” claim any shape for any input, because as from unknown to a concrete type is always allowed with no runtime check attached. loadConfig({ retries: 3 }) has no timeout field at all, but assertType<Config>(raw) asserts it into a Config anyway, so loadConfig’s return type lies about what it actually validated. Calling cfg.timeout.toFixed(0) then crashes with Cannot read properties of undefined (reading 'toFixed'), because cfg.timeout really is undefined at runtime — the generic never checked. The fix is the same theme as the module’s index lesson: a generic type parameter is only as trustworthy as an actual runtime check somewhere in the chain — replace the cast with real validation (a schema library, or explicit field checks) before trusting the shape.

Why constrain a type parameter with extends?
In function getProp<T, K extends keyof T>(obj: T, key: K): T[K], what does the return type T[K] represent?
Is function tag<T>(value: T): Tagged<T> (where Tagged<T> = { value: T }) a "used once" smell?
What does the resulting error say, and why is it confusing?
function both<T>(a: T, b: T): T { return a; }
const y = both(1, "two");
Compared to the both<T> generic above, why can an overload set give a clearer error for the same kind of call?
A generic helper function firstOf<const T>(items: T[]): T is called with firstOf(arr) where const arr = ["a", "b"]. What type does firstOf return, and why?