<T> can be literally anything—a primitive, an object, a function, null, or undefined. Because <T> is completely unconstrained, TypeScript prevents accessing specific properties on T. Generic Constraints (<T extends Shape>) establish minimum structural bounds.1. Unconstrained Generics vs Constrained Generics#
Suppose you want to write a helper function logLength that logs the .length of an item and returns the item.
The Unconstrained Attempt (Fails!)#
// 'T' is completely unconstrained (implicitly bounded by 'unknown')
function logLengthUnconstrained<T>(item: T): T {
// ❌ Compiler Error: Property 'length' does not exist on type 'T'.
// console.log(item.length);
return item;
}Because T could be a number or boolean (which have no .length property at runtime), TypeScript rejects accessing .length.
The Constrained Solution (<T extends Shape>)#
We use the extends keyword inside the angle brackets to declare a Generic Constraint. This tells TypeScript: “T can be any type, provided it satisfies at least the specified structural shape.”
interface HasLength {
length: number;
}
// 🟢 CONSTRAINED: T MUST extend HasLength!
function logLength<T extends HasLength>(item: T): T {
console.log(`Length: ${item.length}`); // 🟢 WORKED!
return item;
}
// 1. Arrays have .length
const arrResult = logLength([10, 20, 30]); // Return Type: number[]
// 2. Strings have .length
const strResult = logLength("TypeScript"); // Return Type: "TypeScript" (or string)
// 3. Custom Objects with .length property
const objResult = logLength({ id: 101, length: 50 }); // Return Type: { id: number; length: number }
// ❌ Compiler Error: Argument of type 'number' is not assignable to parameter of type 'HasLength'.
// logLength(42);
2. Why Use <T extends Shape> Instead of (item: Shape)?#
A common question is: Why write function logLength<T extends HasLength>(item: T): T when we could simply write function logLength(item: HasLength): HasLength?
The answer is Type Information Retention.
Comparison:#
interface HasLength {
length: number;
}
// Approach A: Plain Interface Parameter
function logInterface(item: HasLength): HasLength {
return item;
}
// Approach B: Constrained Generic Parameter
function logGeneric<T extends HasLength>(item: T): T {
return item;
}
const inputString = "Hello World";
// Approach A loses specific type information (Return Type: HasLength!)
const resultA = logInterface(inputString);
// ❌ Error: Property 'toUpperCase' does not exist on type 'HasLength'.
// resultA.toUpperCase();
// Approach B RETAINS the exact concrete subtype (Return Type: string!)
const resultB = logGeneric(inputString);
console.log(resultB.toUpperCase()); // 🟢 WORKED! Result is still 'string'!
| Approach | Return Type for "Hello" | Preserves Subtype Methods? |
|---|---|---|
Plain Interface ((item: HasLength)) | HasLength | ❌ No (Type is wiped to HasLength) |
Constrained Generic (<T extends HasLength>) | string | ✅ Yes (.toUpperCase() remains available) |
3. Constraining Objects (<T extends object> & <T extends Record<...>>)#
If you want to ensure a generic parameter is strictly a non-primitive object, constrain T to object or Record<string, unknown>:
// Constrain T to be any non-primitive object
function mergeObjects<T extends object, U extends object>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
const merged = mergeObjects({ name: "Alice" }, { age: 30 });
// Inferred Return Type: { name: string } & { age: number }
console.log(merged.name, merged.age);
// ❌ Compiler Error: Argument of type 'number' is not assignable to parameter of type 'object'.
// mergeObjects(42, "hello");
4. Key Constraints (<K extends keyof T>)#
One of the most frequent patterns in TypeScript library design is constraining one type parameter using the keyof operator of another type parameter:
function updateProperty<T extends object, K extends keyof T>(
entity: T,
key: K,
newValue: T[K]
): T {
return {
...entity,
[key]: newValue,
};
}
const user = { id: "usr_101", username: "alice", isOnline: true };
// 🟢 WORKED: 'isOnline' is a key of user, and false matches user['isOnline'] type!
const updatedUser = updateProperty(user, "isOnline", false);
// ❌ Compiler Error: Argument of type '"invalid_key"' is not assignable to parameter of type '"id" | "username" | "isOnline"'.
// updateProperty(user, "invalid_key", true);
// ❌ Compiler Error: Argument of type 'number' is not assignable to parameter of type 'boolean'.
// updateProperty(user, "isOnline", 9999);
5. Multiple Constraints via Intersections (<T extends A & B>)#
If a generic parameter must satisfy multiple independent structural contracts simultaneously, use an intersection type inside the extends clause:
interface Identifiable {
id: string;
}
interface Serializable {
serialize(): string;
}
// T MUST satisfy BOTH Identifiable AND Serializable!
function persistEntity<T extends Identifiable & Serializable>(entity: T): string {
console.log(`Persisting Entity ID: ${entity.id}`);
return entity.serialize();
}
const validDoc = {
id: "doc_99",
title: "Report",
serialize() {
return JSON.stringify(this);
},
};
persistEntity(validDoc); // 🟢 WORKED!
Summary & Next Steps#
In this episode:
- We demonstrated how Generic Constraints (
<T extends Shape>) establish lower type bounds on type parameters. - We proved why constrained generics outperform plain interface parameters by preserving concrete subtype return types.
- We constrained generics to non-primitive objects (
<T extends object>). - We combined constraints with
keyof(<K extends keyof T>) to enforce key-value alignment. - We intersected multiple constraints (
<T extends A & B>).
In Episode 31: Default Generic Arguments, we will learn how to make generic parameters optional by providing fallback defaults (<T = string>)!

