Skip to content
JL

Home

About

Blog

Contact

Shop

Portfolio

Privacy

TOS

Click to navigate

  1. Home
  2. Joshua R. Lehman's Blog
  3. Generic Constraints with extends in TypeScript

Table of contents

  • Share on X
  • Discuss on X

Related Articles

Default Generic Parameters in TypeScript
TypeScript
6m
Jun 21, 2026

Default Generic Parameters in TypeScript

Default generic parameters let you make type arguments optional by providing a fallback type when the caller doesn't specify one. They're the reason Result<User> works without writing Result<User, Error> every time — the Error default is already baked in.

#Default Generic Parameters#TypeScript Generics+4
Generic Classes in TypeScript
TypeScript
9m
Jun 7, 2026

Generic Classes in TypeScript

Generic classes let you write a single class that works correctly for any type you provide. TypeScript's built-in Map, Set, and Promise are all generic classes under the hood. In this post, you'll build your own — from a simple typed stack to a fully type-safe event emitter.

#Generic Classes#TypeScript Generics+5
Generic Functions and Type Inference in TypeScript
TypeScript
8m
May 24, 2026

Generic Functions and Type Inference in TypeScript

TypeScript's type inference engine is smart — most of the time it figures out generic type parameters without any help. But knowing *how* inference works lets you write generics that always infer correctly, and knowing *when* it falls short lets you provide explicit type arguments at exactly the right moments.

#Generic Functions#Type Inference+5
Ask me anything! 💬

© Joshua R. Lehman

Full Stack Developer

Crafted with passion • Built with modern web technologies

2026 • All rights reserved

Contents

  • Why Constraints Exist
  • Basic Constraints with extends
  • Constraining to an Object Shape
  • The keyof Constraint
  • Multiple Constraints
  • Real-World Examples
  • Best Practices
  • Key Takeaways
TypeScript

Generic Constraints with extends in TypeScript

June 14, 2026•9 min read
Joshua R. Lehman
Joshua R. Lehman
Author
TypeScript generic constraints with extends keyword for type-safe generic programming
Generic Constraints with extends in TypeScript

Why Constraints Exist

Unconstrained generics are powerful but limited. When T can be literally anything, TypeScript can't let you do much with it — because "anything" includes types that don't support the operation you need.

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

TypeScript rejects this because T might be a number, a boolean, or a custom object with no length property. The fix isn't to give up on generics — it's to constrain T so TypeScript knows it has a length property:

function getLength<T extends { length: number }>(value: T): number {
  return value.length; // OK — T is guaranteed to have length
}
 
getLength("hello"); // 5 — string has length
getLength([1, 2, 3]); // 3 — array has length
getLength({ length: 10 }); // 10 — custom object with length
getLength(42); // Error: number has no 'length' property

This is the core idea: T extends SomeType means "T must be assignable to SomeType." You keep the flexibility of generics while unlocking specific capabilities.

extends Here Is Not Inheritance

In T extends SomeType, extends means structural compatibility — T must have at least the shape of SomeType. It is not the same as class inheritance. A plain object { length: 10 } satisfies T extends { length: number } without inheriting from anything. TypeScript's type system is structural, not nominal.

Basic Constraints with extends

The simplest form of a constraint is extending a primitive union — restricting T to a specific set of primitive types:

function clamp<T extends number>(value: T, min: T, max: T): T {
  if (value < min) return min;
  if (value > max) return max;
  return value;
}
 
clamp(5, 1, 10); // 5
clamp(0, 1, 10); // 1
clamp(15, 1, 10); // 10
clamp("a", "b", "c"); // Error: 'string' does not satisfy 'number'

You can also constrain to a union of types, giving you more flexibility than a single type but more safety than unconstrained T:

function stringify<T extends string | number | boolean>(value: T): string {
  return String(value);
}
 
stringify("hello"); // OK
stringify(42); // OK
stringify(true); // OK
stringify([1, 2]); // Error: array does not satisfy the constraint
stringify(null); // Error: null does not satisfy the constraint

Constraining to a union is useful when you have a function that genuinely works for a limited set of types — narrower than "anything," but broader than one specific type.

Constraining to an Object Shape

The most common constraint pattern is restricting T to an object shape. This lets you access specific properties safely while preserving the full type of whatever was passed in:

interface HasId {
  id: number;
}
 
function findById<T extends HasId>(items: T[], id: number): T | undefined {
  return items.find((item) => item.id === id);
}
 
interface User {
  id: number;
  name: string;
  email: string;
}
 
interface Product {
  id: number;
  name: string;
  price: number;
}
 
const users: User[] = [
  { id: 1, name: "Alice", email: "[email protected]" },
  { id: 2, name: "Bob", email: "[email protected]" },
];
 
const user = findById(users, 1);
// user is User | undefined — not HasId | undefined
// TypeScript preserves the full User type through the constraint
console.log(user?.email); // OK — TypeScript knows it's a User

This is the key benefit of constraining to a shape rather than using the interface directly: the return type is T — the full type of what was passed in — not just HasId. The constraint opens the door to item.id; the type parameter preserves the rest.

// Without generics — you lose the specific type
function findByIdBasic(items: HasId[], id: number): HasId | undefined {
  return items.find((item) => item.id === id);
}
 
const result = findByIdBasic(users, 1);
result?.email; // Error: Property 'email' does not exist on type 'HasId'

Constraint vs Parameter Type

When you write items: HasId[], the return type can only be HasId — you've erased the specific type. When you write <T extends HasId>(items: T[]), the return type can be T — you've preserved the specific type while still requiring the id property. This distinction matters every time a caller needs the full type back.

The keyof Constraint

One of the most powerful constraint patterns combines a type parameter with keyof — the operator that produces a union of an object's keys. This lets you write type-safe property accessor functions:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
 
const user = { id: 1, name: "Alice", email: "[email protected]" };
 
getProperty(user, "name"); // "Alice" — return type is string
getProperty(user, "id"); // 1 — return type is number
getProperty(user, "email"); // "[email protected]" — return type is string
getProperty(user, "age"); // Error: "age" is not a key of the user object

Two type parameters work together here: T is the object type, K extends keyof T constrains the key to valid keys of T, and T[K] is the indexed access type — the type of the value at that key. TypeScript resolves T[K] to the exact type of each property.

This pattern is the foundation for utility functions that need to be key-safe:

function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
  const result = {} as Pick<T, K>;
  keys.forEach((key) => {
    result[key] = obj[key];
  });
  return result;
}
 
const user = {
  id: 1,
  name: "Alice",
  email: "[email protected]",
  role: "admin",
};
 
const summary = pick(user, ["id", "name"]);
// summary: { id: number; name: string }
// TypeScript knows exactly which properties are in the result
 
pick(user, ["id", "unknown"]); // Error: "unknown" is not a key of user

keyof and Indexed Access Types

keyof T produces a union of T's keys: for User, it's "id" | "name" | "email". T[K] (an indexed access type) resolves to the value type at key K: User["id"] is number, User["name"] is string. Together, they're how TypeScript tracks property types through generic functions. A full post on indexed access types is coming later in the series.

Multiple Constraints

A type parameter can only have one extends clause, but you can express multiple constraints by intersecting them:

interface HasId {
  id: number;
}
 
interface HasTimestamps {
  createdAt: Date;
  updatedAt: Date;
}
 
// T must have both id AND timestamps
function logEntity<T extends HasId & HasTimestamps>(entity: T): void {
  console.log(
    `Entity ${entity.id} — created: ${entity.createdAt.toISOString()}`
  );
}
 
interface Post {
  id: number;
  title: string;
  createdAt: Date;
  updatedAt: Date;
}
 
logEntity({
  id: 1,
  title: "Hello",
  createdAt: new Date(),
  updatedAt: new Date(),
}); // OK
logEntity({ id: 1, title: "Hello" }); // Error: missing createdAt, updatedAt

You can also have multiple type parameters with independent constraints:

function mergeObjects<T extends object, U extends object>(
  target: T,
  source: U
): T & U {
  return { ...target, ...source };
}
 
const base = { id: 1, name: "Alice" };
const extra = { role: "admin", active: true };
 
const merged = mergeObjects(base, extra);
// merged: { id: number; name: string } & { role: string; active: boolean }
merged.id; // number
merged.role; // string
merged.active; // boolean

Each type parameter is independently constrained. T extends object and U extends object both prevent primitive types from being passed, while keeping T and U independent of each other.

Real-World Examples

Type-safe deep get:

function deepGet<T extends Record<string, unknown>, K extends keyof T>(
  obj: T,
  key: K
): T[K] {
  return obj[key];
}

Generic sorting with a comparable constraint:

interface Comparable<T> {
  compareTo(other: T): number;
}
 
function sort<T extends Comparable<T>>(items: T[]): T[] {
  return [...items].sort((a, b) => a.compareTo(b));
}
 
class Temperature implements Comparable<Temperature> {
  constructor(public celsius: number) {}
 
  compareTo(other: Temperature): number {
    return this.celsius - other.celsius;
  }
}
 
const temps = [new Temperature(30), new Temperature(10), new Temperature(20)];
sort(temps); // [Temperature(10), Temperature(20), Temperature(30)]

Generic event handler registry:

type Handler<T> = (event: T) => void;
 
function createRegistry<TEvent extends { type: string }>() {
  const handlers = new Map<string, Handler<TEvent>>();
 
  return {
    register<K extends TEvent["type"]>(
      type: K,
      handler: Handler<Extract<TEvent, { type: K }>>
    ): void {
      handlers.set(type, handler as Handler<TEvent>);
    },
    dispatch(event: TEvent): void {
      handlers.get(event.type)?.(event);
    },
  };
}

Merging with a required base shape:

function withDefaults<T extends Partial<Config>, Config>(
  partial: T,
  defaults: Config
): Config & T {
  return { ...defaults, ...partial };
}
 
interface ServerConfig {
  host: string;
  port: number;
  timeout: number;
}
 
const config = withDefaults(
  { port: 8080 },
  { host: "localhost", port: 3000, timeout: 5000 }
);
// config: ServerConfig & { port: number }
// config.host — "localhost" (from defaults)
// config.port — 8080 (overridden)

Constraints Enable Precision

Unconstrained generics are a blank cheque — flexible but not specific enough to be useful. Constraints are what let you cash it: you tell TypeScript exactly what T must have, and in return TypeScript gives you full type safety when you use those properties. The more precise your constraint, the more TypeScript can help you.

Best Practices

Prefer narrow constraints. Constraining to { id: number } instead of object makes your intent clear and gives TypeScript more to work with when resolving types.

Avoid over-constraining. If your function only needs length, constrain to { length: number } — not to Array<unknown> or string. Over-constraining excludes valid callers unnecessarily.

Name your constraint interfaces. Inline constraints like T extends { id: number; name: string; createdAt: Date } work but are hard to read. A named interface makes the intent explicit:

// Harder to read
function process<T extends { id: number; name: string; createdAt: Date }>(
  item: T
): void {}
 
// Clearer
interface Entity {
  id: number;
  name: string;
  createdAt: Date;
}
function process<T extends Entity>(item: T): void {}

Use keyof constraints for property accessor patterns. Any time you're writing a function that takes an object and a property name, reach for <T, K extends keyof T> instead of accepting string keys. You get autocomplete and type safety in one move.

Constraints Are Not Default Values

T extends string means "T must be a string or a subtype of string" — it does not mean "T defaults to string if nothing is provided." Default type parameters (T = string) are a separate feature covered in the next post. A constraint restricts what you can pass; a default fills in the blank when you don't pass anything.

Key Takeaways

  • T extends SomeType constrains what types T can accept — T must be structurally compatible with SomeType
  • extends here means structural compatibility (assignability), not class inheritance
  • Constraining to an object shape (T extends { id: number }) gives TypeScript access to specific properties while preserving the full type through the return value
  • K extends keyof T combined with T[K] as the return type is the foundation for type-safe property accessor functions
  • Multiple constraints use intersection: T extends HasId & HasTimestamps
  • Multiple type parameters can each have their own independent constraint

Up Next: Default Type Parameters

You've mastered constraining what types are allowed. The next post covers default generic parameters — a way to make type arguments optional by providing a fallback when the caller doesn't specify one. Used well, defaults make generic types much more ergonomic without sacrificing any of the safety you just learned.