TypeScriptGeneric Constraints (extends)

Generic Constraints with extends

Without constraints, a type parameter T can be literally anything — which means TypeScript won't let you access any properties on it. Generic constraints (written with the extends keyword) narrow what T is allowed to be, giving you access to specific properties while keeping the function or class reusable.

The Problem: Unconstrained T

TS
// Without a constraint TypeScript knows nothing about T
function getLength<T>(value: T): number {
  return value.length; // ❌ Error: Property 'length' does not exist on type 'T'
}

// T could be a number, boolean, object — anything
// TypeScript can't safely allow .length access
Fixing It with extends

Add extends { length: number } to tell TypeScript that T must have a length property. Now the access is safe — and the function still works for strings, arrays, and any other type with that property.

TS
function getLength<T extends { length: number }>(value: T): number {
  return value.length; // ✅ Safe — T is guaranteed to have .length
}

console.log(getLength('hello'));       // 5
console.log(getLength([1, 2, 3]));    // 3
console.log(getLength({ length: 7 })); // 7

// getLength(42);  // ❌ number doesn't have .length
Note
The constraint T extends SomeType means "T must be assignable to SomeType" — not that T IS SomeType. The caller's concrete type is preserved in the return type.
Constraining to an Interface

TS
interface HasId {
  id: number;
}

// Works for any object that has an id property
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; title: string; price: number }

const users: User[] = [
  { id: 1, name: 'Alice', email: 'alice@example.com' },
  { id: 2, name: 'Bob',   email: 'bob@example.com'   },
];

const products: Product[] = [
  { id: 10, title: 'Keyboard', price: 99 },
  { id: 11, title: 'Mouse',    price: 49 },
];

const user    = findById(users,    1);  // User | undefined
const product = findById(products, 10); // Product | undefined
keyof Constraint — Property Name Safety

Combining extends keyof T with a type parameter ensures a key argument is always a valid property of its object. This is one of the most powerful patterns in TypeScript.

TS
// K must be a key of T
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 1, name: 'Alice', email: 'alice@example.com' };

const name  = getProperty(user, 'name');  // string
const id    = getProperty(user, 'id');    // number
// const bad = getProperty(user, 'age'); // ❌ 'age' is not a key of user

// Strongly typed property setter
function setProperty<T, K extends keyof T>(obj: T, key: K, value: T[K]): void {
  obj[key] = value;
}

setProperty(user, 'name', 'Bob');  // ✅
// setProperty(user, 'name', 42); // ❌ 42 is not assignable to string
Constraining to Primitive Types

TS
// Only accept types that can be used as object keys
function createLookup<T, K extends string | number | symbol>(
  items: T[],
  getKey: (item: T) => K
): Record<K, T> {
  const lookup = {} as Record<K, T>;
  for (const item of items) {
    lookup[getKey(item)] = item;
  }
  return lookup;
}

const users = [
  { id: 1, name: 'Alice' },
  { id: 2, name: 'Bob' },
];

const byId   = createLookup(users, u => u.id);   // Record<number, User>
const byName = createLookup(users, u => u.name); // Record<string, User>

console.log(byId[1].name);    // 'Alice'
console.log(byName['Bob'].id); // 2
Multiple Constraints

TypeScript doesn't have a direct "AND" syntax for multiple extends constraints on a single type parameter, but you can use an intersection type as the constraint.

TS
interface Printable {
  print(): string;
}

interface Serializable {
  serialize(): object;
}

// T must satisfy BOTH interfaces
function process<T extends Printable & Serializable>(item: T): void {
  console.log(item.print());
  const data = item.serialize();
  console.log(JSON.stringify(data));
}

class Report implements Printable, Serializable {
  constructor(private title: string, private data: unknown) {}

  print(): string {
    return `Report: ${this.title}`;
  }

  serialize(): object {
    return { title: this.title, data: this.data };
  }
}

process(new Report('Q4 Sales', { total: 42000 })); // ✅
Constraining One Type Parameter by Another

A later type parameter can extend an earlier one. This is called a constrained type parameter and is useful for expressing relationships between types.

TS
// K must be a key of T (K depends on T)
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: 'alice@example.com', role: 'admin' };

const publicInfo = pick(user, ['name', 'email']); // { name: string; email: string }
// pick(user, ['name', 'password']); // ❌ 'password' is not a key of user

// U must extend T — useful for narrowing
function coerce<T, U extends T>(value: T, guard: (v: T) => v is U): U | null {
  return guard(value) ? value : null;
}

function isString(v: unknown): v is string {
  return typeof v === 'string';
}

const maybeStr: unknown = 'hello';
const str = coerce(maybeStr, isString); // string | null
Constraints in Generic Classes

TS
// A type-safe sorted collection
interface Comparable<T> {
  compareTo(other: T): number; // negative, 0, or positive
}

class SortedList<T extends Comparable<T>> {
  private items: T[] = [];

  insert(item: T): void {
    // Binary search insertion
    let lo = 0, hi = this.items.length;
    while (lo < hi) {
      const mid = (lo + hi) >> 1;
      if (this.items[mid].compareTo(item) < 0) lo = mid + 1;
      else hi = mid;
    }
    this.items.splice(lo, 0, item);
  }

  toArray(): T[] { return [...this.items]; }
}

class Temperature implements Comparable<Temperature> {
  constructor(public celsius: number) {}

  compareTo(other: Temperature): number {
    return this.celsius - other.celsius;
  }
}

const temps = new SortedList<Temperature>();
temps.insert(new Temperature(30));
temps.insert(new Temperature(15));
temps.insert(new Temperature(22));
console.log(temps.toArray().map(t => t.celsius)); // [15, 22, 30]
Conditional Constraints — Checking Type Relationships

TS
// Only allow arrays whose elements extend a given type
function sumNumericField<T extends Record<K, number>, K extends keyof T>(
  items: T[],
  field: K
): number {
  return items.reduce((sum, item) => sum + item[field], 0);
}

const orders = [
  { id: 1, total: 49.99,  tax: 4.00 },
  { id: 2, total: 129.00, tax: 10.32 },
  { id: 3, total: 9.99,   tax: 0.80 },
];

const totalRevenue = sumNumericField(orders, 'total'); // 188.98
const totalTax     = sumNumericField(orders, 'tax');   // 15.12

// sumNumericField(orders, 'id');    // ✅ id is a number field
// sumNumericField(orders, 'label'); // ❌ 'label' doesn't exist
Tip
This pattern — T extends Record<K, number> — is a common interview question and appears in real-world data-processing code. Memorise the shape; understand the mechanics.
The newable Constraint

TS
// Accept a constructor and return an instance
interface Constructor<T> {
  new (...args: unknown[]): T;
}

function create<T>(Ctor: Constructor<T>): T {
  return new Ctor();
}

class Logger {
  log(msg: string) { console.log(msg); }
}

const logger = create(Logger); // Logger — fully typed
logger.log('created via generic factory');

// Generic mixin pattern
function Timestamped<TBase extends Constructor<object>>(Base: TBase) {
  return class extends Base {
    createdAt = new Date();
  };
}

class User { constructor(public name: string) {} }
const TimestampedUser = Timestamped(User);
const u = new TimestampedUser('Alice');
console.log(u.name, u.createdAt); // 'Alice' <Date>
Common Constraint Patterns

Constraint

Meaning

Example use

T extends object

T must be a non-primitive

Deep clone utilities

T extends keyof U

T must be a key of U

Property pickers

T extends string | number

T must be a key-compatible type

Lookup tables

T extends Partial<U>

T is a subset of U

Merge / patch helpers

T extends new(...args) => U

T must be a constructor

Factory functions, mixins

T extends Array<infer E>

T must be an array

Array utility functions

Constraining Return Types

Constraints can appear on return type annotations too, tightening what a function is allowed to return.

TS
// Ensure the returned value is a subset of the input type
function filterKeys<T extends object, K extends keyof T>(
  obj: T,
  keys: K[]
): Pick<T, K> {
  const result = {} as Pick<T, K>;
  keys.forEach(k => { result[k] = obj[k]; });
  return result;
}

const user = { id: 1, name: 'Alice', email: 'alice@example.com', role: 'admin' };
const publicInfo = filterKeys(user, ['name', 'email']);
// { name: string; email: string } — exact type, not just Partial<User>

// Constrain to make a deep-clone utility
function deepClone<T extends object>(obj: T): T {
  return JSON.parse(JSON.stringify(obj));
}

const clone = deepClone({ a: 1, b: { c: 2 } }); // same type as input
Constraint + Default in Practice

TS
interface Validator<T> {
  validate(value: T): boolean;
  message: string;
}

// Chain validators — T is constrained to have a validate method
function composeValidators<T>(
  ...validators: Validator<T>[]
): Validator<T> {
  return {
    validate: (value: T) => validators.every(v => v.validate(value)),
    message: validators.map(v => v.message).join('; '),
  };
}

const isNonEmpty: Validator<string> = {
  validate: (s) => s.trim().length > 0,
  message: 'Must not be empty',
};

const isEmail: Validator<string> = {
  validate: (s) => /^[^s@]+@[^s@]+.[^s@]+$/.test(s),
  message: 'Must be a valid email',
};

const emailValidator = composeValidators(isNonEmpty, isEmail);
console.log(emailValidator.validate('alice@example.com')); // true
console.log(emailValidator.validate('invalid'));            // false
Pitfalls to Avoid
  • Over-constraining T makes the function less general — add extends only when you access properties

  • T extends SomeClass means 'assignable to', not 'exactly equal to' — subclasses are allowed

  • You cannot use extends to constrain to a literal type like T extends "GET" | "POST" in most utility scenarios; use a union parameter type instead

  • Circular constraints (T extends Foo<T>) are allowed but can confuse inference — test carefully

  • Constraints are checked at the call site, not inside the function body — TypeScript trusts that T satisfies the constraint once it is verified

Quick Reference
  • T extends SomeType — T must be assignable to SomeType

  • T extends keyof U — T must be a valid key of U

  • T extends A & B — T must satisfy both A and B

  • U extends T — U is constrained by a previous type param T

  • T extends new() => U — T must be a constructor returning U

  • Constraints enable property access: without them TypeScript refuses to access any member of T

Success
Generic constraints with extends are the bridge between maximum reusability and safe property access. Combined with keyof, they unlock a whole world of type-level programming. Next: default type parameters — giving generics sensible fallbacks.