Skip to main content

TS Ep 30: Generic Constraints (`extends` Keyword)

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
typescript - This article is part of a series.
Part 30: This Article
By default, a generic type parameter <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'!
ApproachReturn Type for "Hello"Preserves Subtype Methods?
Plain Interface ((item: HasLength))HasLengthNo (Type is wiped to HasLength)
Constrained Generic (<T extends HasLength>)stringYes (.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>)!

typescript - This article is part of a series.
Part 30: This Article