Mert Özen Further With Every Line
Frontend TypeScript Intermediate — #02

Generic Constraints in TypeScript: extends, Default Types, const and NoInfer

Mert Özen Nov 3, 2026 7 min 2 views
Generic Constraints in TypeScript: extends, Default Types, const and NoInfer

We constrain type parameters with extends, tie two parameters together with keyof, use default type parameters, and steer inference with TS 5.0 const type parameters and TS 5.4 NoInfer.

Version note: The code in this post targets TypeScript 7.0 and Node 26.

Predict First

We've written a function that returns the longer of two values. Don't worry about the extends next to the type parameter for now; look at the code as a whole:

function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}

const names = longest(["Ada"], ["Grace", "Alan"]);
const count = longest(10, 20);

Two questions: Which line errors? And on the line that doesn't, what is the type of names? The answers come after we've talked about what extends means here.

If T Can Be Anything, You Know Nothing

We ended the previous post with this error:

function getLength<T>(value: T): number {
  // Error: Property 'length' does not exist on type 'T'.
  return value.length;
}

Seen from inside the function, an unconstrained T is a closed box. It might hold a string, a number or null. Since TypeScript doesn't know what's in the box, it allows no properties on it. That's not a shortcoming, it's the correct behavior: if the function can be called with any type, its body has to work with any type.

The fix is to put a label on the box: "Only things that have a length may go in here." That label is called a generic constraint.

Adding a Constraint with extends

function getLength<T extends { length: number }>(value: T): number {
  return value.length;
}

getLength("Ada"); // 3
getLength([1, 2, 3]); // 3
getLength({ length: 10, label: "custom" }); // 10
// Error: Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.
getLength(42);

T extends { length: number } says: "Whatever type T is, it must at least carry a length property of type number." Don't confuse this extends with class inheritance. In a generic context it means "must be assignable to," that is, "must have at least this shape." The structural typing we saw earlier applies here too: strings, arrays and any object with a length meet the requirement, while number doesn't.

Inside the function we can now use value.length, because whatever T is, that property is guaranteed to exist. A constraint works in both directions: outside, it restricts the caller; inside, it gives you a guarantee.

Now back to the opening question. The longest(10, 20) line errors, because number has no length. The type of names is string[]. And this is the key point. If we had written the same function without generics, typing the parameters directly as { length: number }:

function longestPlain(a: { length: number }, b: { length: number }) {
  return a.length >= b.length ? a : b;
}

const names = longestPlain(["Ada"], ["Grace", "Alan"]); // { length: number; }
names.join(", "); // Error: Property 'join' does not exist on type '{ length: number; }'.

The function would still work, but the return type would be { length: number }, and we'd forget that the value in our hands is an array. In the generic version the constraint is just a lower bound; what comes back is still T itself. The constraint says "I expect at least this," and T says "and I keep the whole type you gave me."

Tying One Type Parameter to Another

A constraint doesn't have to be a fixed shape; it can refer to another type parameter. The example you'll see most often is a function that reads a value from an object by key:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: "Ada", age: 36 };

const userName = getProperty(user, "name"); // string
const userAge = getProperty(user, "age"); // number
// Error: Argument of type '"email"' is not assignable to parameter of type '"age" | "name"'.
getProperty(user, "email");

keyof T produces a union of T's keys; here, "name" | "age". K extends keyof T means "K must be one of T's keys." That way we catch a key that doesn't exist while we're still typing it. Don't be thrown by the error message listing the union as "age" | "name"; the order carries no meaning. The T[K] in the return type means "the type of T's property at key K"; for "name" that's string, for "age" it's number. We'll cover keyof and this square-bracket type syntax in detail in upcoming posts; for now it's enough to see that two type parameters can be tied together through a constraint.

Default Type Parameters

Just as you can give regular parameters default values, you can give type parameters default types:

function createList<T = string>(): T[] {
  return [];
}

const tags = createList(); // string[]
const scores = createList<number>(); // number[]

This function has no parameters, so there's no argument TypeScript could infer T from. Without the default, T would be unknown in the createList() call and we'd get unknown[]. A default type kicks in exactly when there's no candidate to infer from. If T can be inferred from an argument, inference wins.

There's one rule: type parameters with defaults must come after those without. Write <T = string, U> and you get Required type parameters may not follow optional type parameters. (TS2706). Default type parameters really shine in generic types; we'll meet them there again in the next post.

const Type Parameters

Earlier we used as const to have a value inferred as literal and readonly. In generic functions we sometimes want to keep the caller's literal values too. The const type parameter that came with TS 5.0 does exactly that:

function defineRoutes<T extends readonly string[]>(paths: T): T {
  return paths;
}

function defineConstRoutes<const T extends readonly string[]>(paths: T): T {
  return paths;
}

const routes = defineRoutes(["/home", "/about"]); // string[]
const constRoutes = defineConstRoutes(["/home", "/about"]); // readonly ["/home", "/about"]

In the first function TypeScript widens the array to string[] as usual. In the second, the const in front of the type parameter tells it to infer the argument as if as const had been written, and the result is readonly ["/home", "/about"]. The caller no longer has to remember to write as const every time; the function has taken on that responsibility.

A small but important detail: we wrote the constraint as readonly string[]. If the constraint were string[], TypeScript would still keep the literals, but the result would be the non-readonly tuple ["/home", "/about"]. If you want a readonly result as with as const, make the constraint readonly too; a readonly constraint also lets the function accept both regular and readonly arrays.

NoInfer: Don't Infer from This Parameter

Sometimes a type parameter appears in more than one place, but you want T to be inferred from only one of them. Look at this example:

function pickColor<C extends string>(colors: C[], fallback: C): C {
  return colors[0] ?? fallback;
}

const color = pickColor(["red", "green"], "blue"); // "blue" | "green" | "red"

What we meant was: fallback must be one of the colors in the list. But while inferring C, TypeScript looked at both parameters and added "blue" to the candidates. Instead of catching the mistake, we got a wider union. The NoInfer utility type, which came with TS 5.4, keeps a parameter out of inference:

function pickColor<C extends string>(colors: C[], fallback: NoInfer<C>): C {
  return colors[0] ?? fallback;
}

const color = pickColor(["red", "green"], "red"); // "green" | "red"
// Error: Argument of type '"blue"' is not assignable to parameter of type '"green" | "red"'.
pickColor(["red", "green"], "blue");

Now C is inferred only from colors, and fallback is only checked. Here's a way to hold it in your head: NoInfer tells that parameter "you don't decide, you just comply." It doesn't change anything about the type itself; it only decides which source inference draws from.

Common Pitfalls

The most common mistake is trying to return a value that satisfies the constraint in place of T:

function withDefaultId<T extends { id: number }>(item: T): T {
  // Error: Type '{ id: number; }' is not assignable to type 'T'.
  return { id: 1 };
}

The rest of the message says it all: '{ id: number; }' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint '{ id: number; }'. In other words, { id: 1 } satisfies the constraint, but T isn't the constraint itself. If someone calls this function with { id: 5, name: "Ada" }, the return type promises an object with a name; the object you return has none. A constraint is a lower bound, not the exact meaning of T.

The second pitfall is adding a constraint to a generic that doesn't need to be generic. If T appears only once, then, as we saw in the previous post, you don't need a generic at all; making the parameter type the constraint itself is enough. function logLength(value: { length: number }): void does the same job as function logLength<T extends { length: number }>(value: T): void.

The third is thinking of class inheritance when you see the word extends. In generics, extends doesn't build a class hierarchy; it only sets a "must be assignable" requirement. T extends string accepts a string literal type as well as string itself.

A Small Experiment

★☆☆ Predict whether it compiles: longest("Ada", ["Grace"]). If it errors, read which type the message says was inferred for T, and think about why.

★★☆ Add a parameter to createList<T = string>(): createList<T = string>(...items: T[]): T[]. Predict the types of the createList() and createList(1, 2) calls. In which one does the default type kick in?

★★★ Combine the NoInfer version of pickColor with a const type parameter: <const C extends string>. Then look at the return type of pickColor(["red", "green"], "green") and explain whether const makes a difference here.

So far we've only used generics in functions. But a type parameter can also be given to a type alias, an interface or a class. In the next post we'll take generics into data structures by writing Box<T>, Result<T, E> and a generic Stack class.