Generic constraints let you restrict what types can be used with your generic code. Instead of accepting any type, you can require that types have specific properties or extend certain interfaces. This gives you the flexibility of generics while ensuring types meet your requirements. Let's master generic constraints!
Why Use Generic Constraints?
Sometimes generic types need to have certain properties or capabilities. Constraints ensure this.
// Problem: Can't access properties on unconstrained generic
function logLength<T>(arg: T): void {
// console.log(arg.length); // ❌ Error: T might not have 'length'
}
// Solution: Add constraint with 'extends'
function logLengthConstrained<T extends { length: number }>(arg: T): void {
console.log(arg.length); // ✅ Works - T must have length
}
// Now works with anything that has length property
logLengthConstrained("hello"); // ✅ string has length
logLengthConstrained([1, 2, 3]); // ✅ array has length
logLengthConstrained({ length: 5 }); // ✅ object with length property
// logLengthConstrained(42); // ❌ Error: number doesn't have length
// Another example: Ensuring objects have specific properties
function getProperty<T, K>(obj: T, key: K): any {
// return obj[key]; // ❌ Error: T might not have property K
}
// Solution: Constrain K to be keys of T
function getPropertyConstrained<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]; // ✅ Works - K is guaranteed to be a key of T
}
const person = { name: "Yusuf", age: 25, email: "yusuf@example.com" };
const name = getPropertyConstrained(person, "name"); // Type: string
const age = getPropertyConstrained(person, "age"); // Type: number
// const invalid = getPropertyConstrained(person, "invalid"); // ❌ Error
console.log(name); // "Yusuf"
console.log(age); // 25Basic Constraints with extends
Use extends to specify that a generic type must be a subtype of another type.
Constraining to Interfaces
// Define an interface
interface HasId {
id: number;
}
// Constrain generic to types that have an id
function printId<T extends HasId>(obj: T): void {
console.log(`ID: ${obj.id}`);
}
// Works with any type that has an id
printId({ id: 1, name: "Yusuf" }); // ✅ Works
printId({ id: 2, title: "Product" }); // ✅ Works
// printId({ name: "Amina" }); // ❌ Error: missing id
// More complex constraint
interface Timestamped {
createdAt: Date;
updatedAt: Date;
}
function logTimestamps<T extends Timestamped>(obj: T): void {
console.log(`Created: ${obj.createdAt.toISOString()}`);
console.log(`Updated: ${obj.updatedAt.toISOString()}`);
}
const record = {
id: 1,
name: "User Record",
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-12-25")
};
logTimestamps(record); // ✅ Works
// Combining multiple constraints with intersection
interface Named {
name: string;
}
function displayEntity<T extends HasId & Named>(entity: T): void {
console.log(`${entity.name} (ID: ${entity.id})`);
}
displayEntity({ id: 1, name: "Yusuf" }); // ✅ Works
// displayEntity({ id: 1 }); // ❌ Error: missing name
// displayEntity({ name: "Amina" }); // ❌ Error: missing idConstraining to Primitive Types
// Constrain to string
function toUpperCase<T extends string>(str: T): Uppercase<T> {
return str.toUpperCase() as Uppercase<T>;
}
const upper = toUpperCase("hello"); // Type: "HELLO"
console.log(upper); // "HELLO"
// Constrain to number
function double<T extends number>(num: T): number {
return num * 2;
}
console.log(double(5)); // 10
console.log(double(2.5)); // 5
// double("5"); // ❌ Error: string not assignable to number
// Constrain to array
function getFirst<T extends any[]>(arr: T): T[0] {
return arr[0];
}
const first = getFirst([1, 2, 3]); // Type: number
const firstStr = getFirst(["a", "b"]); // Type: string
// getFirst("string"); // ❌ Error: string not assignable to array
// Constrain to object
function clone<T extends object>(obj: T): T {
return { ...obj };
}
const cloned = clone({ name: "Yusuf", age: 25 });
console.log(cloned); // { name: "Yusuf", age: 25 }
// clone(42); // ❌ Error: number not assignable to object
// clone("string"); // ❌ Error: string not assignable to objectUsing keyof in Constraints
The keyof operator creates a union of all property keys. Combined with constraints, it ensures type-safe property access.
// Safe property access with keyof
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = {
id: 1,
name: "Yusuf Ibrahim",
email: "yusuf@example.com",
age: 25
};
const name = getProperty(user, "name"); // Type: string
const age = getProperty(user, "age"); // Type: number
const id = getProperty(user, "id"); // Type: number
// const invalid = getProperty(user, "invalid"); // ❌ Error: not a key
console.log(name); // "Yusuf Ibrahim"
console.log(age); // 25
// Set property safely
function setProperty<T, K extends keyof T>(
obj: T,
key: K,
value: T[K]
): void {
obj[key] = value;
}
setProperty(user, "name", "Yusuf A. Ibrahim"); // ✅ Works
setProperty(user, "age", 26); // ✅ Works
// setProperty(user, "age", "twenty-six"); // ❌ Error: wrong type
// setProperty(user, "invalid", "value"); // ❌ Error: not a key
console.log(user.name); // "Yusuf A. Ibrahim"
console.log(user.age); // 26
// Multiple properties
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 subset = pick(user, "name", "email");
// Type: { name: string; email: string }
console.log(subset); // { name: "Yusuf A. Ibrahim", email: "yusuf@example.com" }
// Omit properties
function omit<T, K extends keyof T>(obj: T, ...keys: K[]): Omit<T, K> {
const result = { ...obj };
keys.forEach(key => {
delete result[key];
});
return result;
}
const withoutAge = omit(user, "age");
// Type: { id: number; name: string; email: string }
console.log(withoutAge); // { id: 1, name: "Yusuf A. Ibrahim", email: "yusuf@example.com" }Multiple Type Parameters with Constraints
// Two constrained type parameters
function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
const person = { name: "Yusuf", age: 25 };
const contact = { email: "yusuf@example.com", phone: "+234-803-123-4567" };
const combined = merge(person, contact);
// Type: { name: string; age: number } & { email: string; phone: string }
console.log(combined.name); // "Yusuf"
console.log(combined.email); // "yusuf@example.com"
// merge({ name: "Test" }, "string"); // ❌ Error: string not assignable to object
// merge(42, { x: 10 }); // ❌ Error: number not assignable to object
// Constrain second parameter based on first
function mapObject<T extends object, K extends keyof T, U>(
obj: T,
key: K,
mapper: (value: T[K]) => U
): Record<K, U> {
return { [key]: mapper(obj[key]) } as Record<K, U>;
}
const user = { name: "Yusuf", age: 25 };
const mapped = mapObject(user, "age", (age) => age * 2);
// Type: Record<"age", number>
console.log(mapped); // { age: 50 }
// Array constraints with relationships
function zip<T, U>(arr1: T[], arr2: U[]): [T, U][] {
const length = Math.min(arr1.length, arr2.length);
const result: [T, U][] = [];
for (let i = 0; i < length; i++) {
result.push([arr1[i], arr2[i]]);
}
return result;
}
const numbers = [1, 2, 3];
const letters = ["a", "b", "c"];
const zipped = zip(numbers, letters);
// Type: [number, string][]
console.log(zipped); // [[1, "a"], [2, "b"], [3, "c"]]
// Group by key
function groupBy<T extends object, K extends keyof T>(
items: T[],
key: K
): Record<string, T[]> {
const groups: Record<string, T[]> = {};
items.forEach(item => {
const groupKey = String(item[key]);
if (!groups[groupKey]) {
groups[groupKey] = [];
}
groups[groupKey].push(item);
});
return groups;
}
const users = [
{ name: "Yusuf", role: "admin", age: 25 },
{ name: "Amina", role: "editor", age: 30 },
{ name: "Chidi", role: "admin", age: 28 }
];
const byRole = groupBy(users, "role");
console.log(byRole);
// {
// admin: [{ name: "Yusuf", ... }, { name: "Chidi", ... }],
// editor: [{ name: "Amina", ... }]
// }Generic Constraints in Classes
// Generic class with constraint
interface Entity {
id: number;
}
class Repository<T extends Entity> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
findById(id: number): T | undefined {
return this.items.find(item => item.id === id);
}
getAll(): T[] {
return [...this.items];
}
remove(id: number): boolean {
const index = this.items.findIndex(item => item.id === id);
if (index === -1) return false;
this.items.splice(index, 1);
return true;
}
}
// User type extends Entity
interface User extends Entity {
name: string;
email: string;
}
// Product type extends Entity
interface Product extends Entity {
name: string;
price: number;
}
// Create repositories
const userRepo = new Repository<User>();
const productRepo = new Repository<Product>();
// Use repositories
userRepo.add({ id: 1, name: "Yusuf", email: "yusuf@example.com" });
userRepo.add({ id: 2, name: "Amina", email: "amina@example.com" });
const user = userRepo.findById(1);
console.log(user); // { id: 1, name: "Yusuf", email: "yusuf@example.com" }
productRepo.add({ id: 101, name: "Laptop", price: 450000 });
productRepo.add({ id: 102, name: "Mouse", price: 5000 });
const products = productRepo.getAll();
console.log(products);
// Error if type doesn't extend Entity
// class BadRepo extends Repository<string> {} // ❌ Error: string doesn't extend Entity
// Generic class with multiple constraints
interface Comparable {
compareTo(other: this): number;
}
class SortedList<T extends Comparable> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
this.sort();
}
private sort(): void {
this.items.sort((a, b) => a.compareTo(b));
}
getAll(): T[] {
return [...this.items];
}
}
class Price implements Comparable {
constructor(public amount: number, public currency: string) {}
compareTo(other: Price): number {
return this.amount - other.amount;
}
}
const prices = new SortedList<Price>();
prices.add(new Price(50000, "NGN"));
prices.add(new Price(25000, "NGN"));
prices.add(new Price(75000, "NGN"));
console.log(prices.getAll());
// Sorted by amount: [25000, 50000, 75000]Default Type Parameters
You can provide default types for generic parameters, making them optional.
// Generic with default type
interface ApiResponse<T = any> {
success: boolean;
data: T;
message?: string;
}
// Can use without specifying type (defaults to 'any')
const response1: ApiResponse = {
success: true,
data: { user: "Yusuf" }
};
// Or specify exact type
const response2: ApiResponse<{ id: number; name: string }> = {
success: true,
data: { id: 1, name: "Yusuf" }
};
console.log(response2.data.name); // "Yusuf" (type-safe)
// Function with default type parameter
function createArray<T = string>(length: number, value: T): T[] {
return Array(length).fill(value);
}
const strings = createArray(3, "hello"); // Inferred as string[]
const numbers = createArray<number>(3, 42); // Explicitly number[]
const defaults = createArray(3, "test"); // Uses default: string[]
console.log(strings); // ["hello", "hello", "hello"]
console.log(numbers); // [42, 42, 42]
// Class with default type parameters
class Container<T = string> {
constructor(private value: T) {}
getValue(): T {
return this.value;
}
setValue(value: T): void {
this.value = value;
}
}
const stringContainer = new Container("hello"); // Defaults to Container<string>
const numberContainer = new Container<number>(42); // Explicitly Container<number>
console.log(stringContainer.getValue()); // "hello"
console.log(numberContainer.getValue()); // 42
// Multiple defaults with constraints
interface Result<T = any, E = Error> {
success: boolean;
data?: T;
error?: E;
}
const result1: Result = {
success: false,
error: new Error("Something failed")
};
const result2: Result<string, string> = {
success: false,
error: "Custom error message"
};
const result3: Result<{ id: number }> = {
success: true,
data: { id: 1 }
};Practical Examples
Example 1: Type-Safe Event System
// Event must have a type property
interface Event {
type: string;
}
// Generic event emitter with constraint
class EventEmitter<T extends Event> {
private listeners = new Map<string, Array<(event: T) => void>>();
on(eventType: T["type"], listener: (event: T) => void): void {
if (!this.listeners.has(eventType)) {
this.listeners.set(eventType, []);
}
this.listeners.get(eventType)!.push(listener);
}
emit(event: T): void {
const listeners = this.listeners.get(event.type);
if (listeners) {
listeners.forEach(listener => listener(event));
}
}
off(eventType: T["type"], listener: (event: T) => void): void {
const listeners = this.listeners.get(eventType);
if (listeners) {
const index = listeners.indexOf(listener);
if (index !== -1) {
listeners.splice(index, 1);
}
}
}
}
// Define event types
interface UserLoginEvent extends Event {
type: "user:login";
userId: number;
username: string;
timestamp: Date;
}
interface UserLogoutEvent extends Event {
type: "user:logout";
userId: number;
timestamp: Date;
}
type UserEvent = UserLoginEvent | UserLogoutEvent;
// Create typed emitter
const userEmitter = new EventEmitter<UserEvent>();
// Add listeners
userEmitter.on("user:login", (event) => {
// TypeScript knows this is UserLoginEvent
console.log(`User ${event.username} logged in`);
console.log(`User ID: ${event.userId}`);
});
userEmitter.on("user:logout", (event) => {
// TypeScript knows this is UserLogoutEvent
console.log(`User ${event.userId} logged out`);
});
// Emit events
userEmitter.emit({
type: "user:login",
userId: 1,
username: "Yusuf",
timestamp: new Date()
});
userEmitter.emit({
type: "user:logout",
userId: 1,
timestamp: new Date()
});Example 2: Query Builder
// Base type must be an object
class QueryBuilder<T extends object> {
private conditions: Array<(item: T) => boolean> = [];
private sortKey?: keyof T;
private sortDirection: "asc" | "desc" = "asc";
private limitValue?: number;
where(predicate: (item: T) => boolean): this {
this.conditions.push(predicate);
return this;
}
orderBy(key: keyof T, direction: "asc" | "desc" = "asc"): this {
this.sortKey = key;
this.sortDirection = direction;
return this;
}
limit(count: number): this {
this.limitValue = count;
return this;
}
execute(data: T[]): T[] {
let results = [...data];
// Apply conditions
this.conditions.forEach(condition => {
results = results.filter(condition);
});
// Apply sorting
if (this.sortKey) {
results.sort((a, b) => {
const aVal = a[this.sortKey!];
const bVal = b[this.sortKey!];
if (aVal < bVal) return this.sortDirection === "asc" ? -1 : 1;
if (aVal > bVal) return this.sortDirection === "asc" ? 1 : -1;
return 0;
});
}
// Apply limit
if (this.limitValue) {
results = results.slice(0, this.limitValue);
}
return results;
}
}
// Define data type
interface Product {
id: number;
name: string;
price: number;
category: string;
inStock: boolean;
}
const products: Product[] = [
{ id: 1, name: "Laptop", price: 450000, category: "Electronics", inStock: true },
{ id: 2, name: "Mouse", price: 5000, category: "Electronics", inStock: true },
{ id: 3, name: "Desk", price: 75000, category: "Furniture", inStock: false },
{ id: 4, name: "Chair", price: 35000, category: "Furniture", inStock: true },
{ id: 5, name: "Monitor", price: 120000, category: "Electronics", inStock: true }
];
// Use query builder
const query = new QueryBuilder<Product>();
const results = query
.where(p => p.category === "Electronics")
.where(p => p.inStock === true)
.orderBy("price", "desc")
.limit(2)
.execute(products);
console.log(results);
// [
// { id: 1, name: "Laptop", price: 450000, ... },
// { id: 5, name: "Monitor", price: 120000, ... }
// ]
// Another query
const cheapFurniture = new QueryBuilder<Product>()
.where(p => p.category === "Furniture")
.where(p => p.price < 50000)
.execute(products);
console.log(cheapFurniture);
// [{ id: 4, name: "Chair", price: 35000, ... }]Example 3: Validation Framework
// Validator interface
interface Validator<T> {
validate(value: T): { valid: boolean; error?: string };
}
// Generic validation builder
class ValidationBuilder<T extends object> {
private validators = new Map<keyof T, Validator<any>[]>();
addRule<K extends keyof T>(
field: K,
validator: Validator<T[K]>
): this {
if (!this.validators.has(field)) {
this.validators.set(field, []);
}
this.validators.get(field)!.push(validator);
return this;
}
validate(obj: T): { valid: boolean; errors: Record<string, string[]> } {
const errors: Record<string, string[]> = {};
let valid = true;
this.validators.forEach((validators, field) => {
const fieldErrors: string[] = [];
const value = obj[field];
validators.forEach(validator => {
const result = validator.validate(value);
if (!result.valid && result.error) {
fieldErrors.push(result.error);
valid = false;
}
});
if (fieldErrors.length > 0) {
errors[field as string] = fieldErrors;
}
});
return { valid, errors };
}
}
// Create validators
const minLength = (min: number): Validator<string> => ({
validate(value: string) {
if (value.length >= min) {
return { valid: true };
}
return { valid: false, error: `Must be at least ${min} characters` };
}
});
const maxLength = (max: number): Validator<string> => ({
validate(value: string) {
if (value.length <= max) {
return { valid: true };
}
return { valid: false, error: `Must be at most ${max} characters` };
}
});
const email: Validator<string> = {
validate(value: string) {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (regex.test(value)) {
return { valid: true };
}
return { valid: false, error: "Invalid email format" };
}
};
const min = (minValue: number): Validator<number> => ({
validate(value: number) {
if (value >= minValue) {
return { valid: true };
}
return { valid: false, error: `Must be at least ${minValue}` };
}
});
// Define form type
interface RegistrationForm {
username: string;
email: string;
password: string;
age: number;
}
// Build validator
const validator = new ValidationBuilder<RegistrationForm>()
.addRule("username", minLength(3))
.addRule("username", maxLength(20))
.addRule("email", email)
.addRule("password", minLength(8))
.addRule("age", min(18));
// Validate data
const validData: RegistrationForm = {
username: "yusuf",
email: "yusuf@example.com",
password: "securepassword",
age: 25
};
const result1 = validator.validate(validData);
console.log(result1); // { valid: true, errors: {} }
const invalidData: RegistrationForm = {
username: "ab",
email: "invalid-email",
password: "short",
age: 15
};
const result2 = validator.validate(invalidData);
console.log(result2);
// {
// valid: false,
// errors: {
// username: ["Must be at least 3 characters"],
// email: ["Invalid email format"],
// password: ["Must be at least 8 characters"],
// age: ["Must be at least 18"]
// }
// }Test Your Knowledge
Type Safety Check
Which generic constraint is correct?
// Option A
function getLength<T>(arg: T): number {
return arg.length;
}
// Option B
function getLength<T extends { length: number }>(arg: T): number {
return arg.length;
}
// Option C
function getLength<T extends string | Array>(arg: T): number {
return arg.length;
}
// Option D
function getLength<T>(arg: T extends { length: number }): number {
return arg.length;
}Common TypeScript Error
// Function without constraint - error!
function printLength<T>(arg: T): void {
console.log(arg.length); // ❌ Error: Property 'length' doesn't exist on type 'T'
}
// Trying to use it
printLength("hello"); // We know strings have length
printLength([1, 2, 3]); // We know arrays have length
printLength({ x: 10 }); // This doesn't have length - should error!❌ Property 'length' does not exist on type 'T'
What's Wrong?
Without a constraint, TypeScript doesn't know if the generic type T has a 'length' property. Even though some types like strings and arrays have it, TypeScript needs a guarantee.
// Function with constraint - works!
function printLength<T extends { length: number }>(arg: T): void {
console.log(arg.length); // ✅ Safe - T is guaranteed to have length
}
// Now type-safe
printLength("hello"); // ✅ Works - string has length
printLength([1, 2, 3]); // ✅ Works - array has length
// printLength({ x: 10 }); // ❌ Error - object doesn't have length (caught at compile-time!)
// Or more specific constraint
function printArrayLength<T>(arg: T[]): void {
console.log(arg.length); // ✅ Safe - arrays always have length
}
printArrayLength([1, 2, 3]); // ✅ Works
printArrayLength(["a", "b"]); // ✅ Works
// printArrayLength("string"); // ❌ Error - string is not an arraySolution: Add a constraint to ensure T has a length property
Best Practices
- Use constraints to ensure type safety - specify requirements
- Prefer specific constraints over loose ones
- Use keyof for property access - type-safe keys
- Combine constraints with intersections for multiple requirements
- Provide defaults when appropriate - better DX
- Document complex constraints with comments
- Keep constraints minimal - only what's needed
- Test with various types to verify constraints work
// ✅ Good: Specific constraint
function printId<T extends { id: number }>(obj: T): void {
console.log(`ID: ${obj.id}`);
}
// ✅ Good: keyof constraint for type-safe access
function getValue<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
// ✅ Good: Multiple constraints with intersection
interface HasId { id: number; }
interface HasName { name: string; }
function display<T extends HasId & HasName>(obj: T): void {
console.log(`${obj.name} (${obj.id})`);
}
// ✅ Good: Default type parameter
interface Response<T = any> {
data: T;
status: number;
}
// ✅ Good: Constrain to object for spread
function merge<T extends object, U extends object>(a: T, b: U): T & U {
return { ...a, ...b };
}
// ❌ Avoid: Too loose constraint
function badExample<T extends any>(value: T): void {
// 'extends any' is redundant - all types extend any
}
// ❌ Avoid: Over-constraining
function tooSpecific<T extends string & number>(value: T): void {
// Impossible: nothing can be both string AND number
}
// ✅ Better: Use union for OR
function better<T extends string | number>(value: T): void {
// T can be string OR number
}Key Takeaways
- Generic constraints restrict what types can be used
- Use
extendsto specify type requirements keyofcreates type-safe property access- Constraints ensure generic code can safely use properties/methods
- Multiple constraints use intersection types (
&) - Default type parameters make generics more flexible
- Constraints work with functions, classes, and interfaces
T extends Umeans T must be a subtype of U- Use specific constraints for better type safety
- Constraints enable powerful, reusable patterns
What's Next?
Excellent work! You've mastered generic constraints and can now create sophisticated, type-safe generic code. Next, we'll explore Utility Types—TypeScript's built-in generic types like Partial, Pick, Omit, and Record. These powerful tools let you transform and manipulate types easily. This completes the Generics category!
Get ready to master TypeScript's utility type toolkit!