Prerequisites
- Strong understanding of TypeScript generics 📝
- Knowledge of interfaces and types 🔍
- Familiarity with type system basics 💻
What you'll learn
- Apply powerful constraints to generic types 🎯
- Create flexible yet type-safe APIs 🏗️
- Master advanced constraint patterns 🛡️
- Build professional generic utilities ✨
🎯 Introduction
Welcome to the world of generic constraints! 🎉 In this guide, we’ll explore how to harness the power of constraints to create generic code that’s both flexible and type-safe.
You’ll discover how generic constraints are like security checkpoints 🔒 - they ensure only the right types can pass through! Whether you’re building utility libraries 🔧, designing APIs 🌐, or creating complex type systems 🏗️, mastering generic constraints is essential for writing professional TypeScript code.
By the end of this tutorial, you’ll be confidently applying constraints to create powerful, safe generic patterns! Let’s unlock the power of controlled generics! 🏊♂️
📚 Understanding Generic Constraints
🤔 Why Generic Constraints?
Generic constraints allow you to restrict what types can be used with your generic code:
// ❌ Without constraints - Too permissive
function getLength<T>(item: T): number {
return item.length; // Error: Property 'length' does not exist on type 'T'
}
// ✅ With constraints - Type safe!
function getLength<T extends { length: number }>(item: T): number {
return item.length; // Works! TypeScript knows T has length
}
getLength("hello"); // ✅ string has length
getLength([1, 2, 3]); // ✅ array has length
getLength({ length: 5 }); // ✅ object with length
// getLength(42); // ❌ Error: number doesn't have length
// 🎯 Real-world example
interface Serializable {
serialize(): string;
}
function saveToStorage<T extends Serializable>(item: T): void {
const serialized = item.serialize(); // Safe because T extends Serializable
localStorage.setItem('data', serialized);
}
class User implements Serializable {
constructor(public name: string, public age: number) {}
serialize(): string {
return JSON.stringify({ name: this.name, age: this.age });
}
}
saveToStorage(new User("Alice", 30)); // ✅ Works!
// saveToStorage({ name: "Bob" }); // ❌ Error: missing serialize method
💡 Basic Constraint Patterns
Common ways to constrain generic types:
// 🎯 Extending interfaces
interface HasId {
id: string | number;
}
function findById<T extends HasId>(items: T[], id: string | number): T | undefined {
return items.find(item => item.id === id);
}
// 🏗️ Extending types
type Comparable = string | number | Date;
function sort<T extends Comparable>(items: T[]): T[] {
return items.sort((a, b) => {
if (a < b) return -1;
if (a > b) return 1;
return 0;
});
}
// 🔧 Extending classes
class Animal {
name: string;
constructor(name: string) {
this.name = name;
}
}
function feedAnimals<T extends Animal>(animals: T[]): void {
animals.forEach(animal => {
console.log(`Feeding ${animal.name}`);
});
}
// 🎨 Multiple constraints
interface Timestamped {
createdAt: Date;
updatedAt: Date;
}
interface Identifiable {
id: string;
}
function updateEntity<T extends Timestamped & Identifiable>(
entity: T
): T {
return {
...entity,
updatedAt: new Date()
};
}
🚀 Advanced Constraint Techniques
🔑 keyof Constraints
Using keyof for property-based constraints:
// 🎯 Basic keyof constraint
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const person = { name: "Alice", age: 30, email: "[email protected]" };
const name = getProperty(person, "name"); // string
const age = getProperty(person, "age"); // number
// getProperty(person, "invalid"); // ❌ Error: "invalid" is not a key
// 🏗️ Advanced keyof patterns
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(person, ["name", "email"]); // { name: string; email: string }
// 🔧 Nested property access
type DeepKeys<T> = T extends object
? {
[K in keyof T]: K extends string
? T[K] extends object
? K | `${K}.${DeepKeys<T[K]>}`
: K
: never;
}[keyof T]
: never;
function getDeepProperty<T, K extends DeepKeys<T>>(
obj: T,
path: K
): any {
const keys = (path as string).split('.');
let result: any = obj;
for (const key of keys) {
result = result[key];
}
return result;
}
const data = {
user: {
profile: {
name: "Alice",
settings: {
theme: "dark"
}
}
}
};
const theme = getDeepProperty(data, "user.profile.settings.theme"); // Works!
// 🎨 Constraining to specific key types
type StringKeys<T> = {
[K in keyof T]: T[K] extends string ? K : never;
}[keyof T];
type NumberKeys<T> = {
[K in keyof T]: T[K] extends number ? K : never;
}[keyof T];
function getStringProperty<T, K extends StringKeys<T>>(
obj: T,
key: K
): string {
return obj[key] as any;
}
function getNumberProperty<T, K extends NumberKeys<T>>(
obj: T,
key: K
): number {
return obj[key] as any;
}
const stringProp = getStringProperty(person, "name"); // ✅ string
const numberProp = getNumberProperty(person, "age"); // ✅ number
// getStringProperty(person, "age"); // ❌ Error: age is not a string property
🎭 Conditional Constraints
Using conditional types in constraints:
// 🎯 Basic conditional constraints
type IsArray<T> = T extends any[] ? true : false;
function processValue<T>(
value: T,
isArray: IsArray<T>
): T {
if (isArray) {
console.log("Processing array:", value);
} else {
console.log("Processing single value:", value);
}
return value;
}
// 🏗️ Extract array element type
type ElementType<T> = T extends (infer E)[] ? E : T;
function first<T extends any[]>(
array: T
): ElementType<T> | undefined {
return array[0];
}
const numbers = [1, 2, 3];
const firstNumber = first(numbers); // number
// 🔧 Conditional method availability
interface AsyncData<T> {
value: T;
loading: boolean;
error?: Error;
}
type AsyncMethod<T> = T extends Promise<infer U>
? { then: (value: U) => void }
: { getValue: () => T };
function handleData<T>(
data: AsyncData<T>
): T extends Promise<any> ? void : T {
if (data.loading) {
throw new Error("Data is still loading");
}
if (data.error) {
throw data.error;
}
return data.value as any;
}
// 🎨 Complex conditional constraints
type DeepPartial<T> = T extends object
? { [P in keyof T]?: DeepPartial<T[P]> }
: T;
function merge<T extends object>(
target: T,
source: DeepPartial<T>
): T {
const result = { ...target };
for (const key in source) {
if (source[key] !== undefined) {
if (typeof source[key] === 'object' && !Array.isArray(source[key])) {
result[key] = merge(
target[key] as any,
source[key] as any
);
} else {
result[key] = source[key] as any;
}
}
}
return result;
}
🎪 Real-World Constraint Patterns
🏭 Factory Constraints
Building type-safe factories with constraints:
// 🎯 Constructor constraint
interface Constructable<T = {}> {
new (...args: any[]): T;
}
function createInstance<T extends Constructable>(
ctor: T,
...args: ConstructorParameters<T>
): InstanceType<T> {
return new ctor(...args);
}
class User {
constructor(public name: string, public age: number) {}
}
const user = createInstance(User, "Alice", 30); // User instance
// 🏗️ Factory with interface constraints
interface Entity {
id: string;
createdAt: Date;
}
interface EntityConstructor<T extends Entity> {
new (data: Omit<T, 'id' | 'createdAt'>): T;
}
class EntityFactory<T extends Entity> {
private idCounter = 0;
constructor(private EntityClass: EntityConstructor<T>) {}
create(data: Omit<T, 'id' | 'createdAt'>): T {
return new this.EntityClass({
...data,
id: `${++this.idCounter}`,
createdAt: new Date()
} as any);
}
createMany(dataArray: Omit<T, 'id' | 'createdAt'>[]): T[] {
return dataArray.map(data => this.create(data));
}
}
// 🔧 Advanced factory patterns
interface Validator<T> {
validate(value: T): boolean;
errors: string[];
}
interface Serializer<T> {
serialize(value: T): string;
deserialize(data: string): T;
}
class ModelFactory<
T extends Entity,
V extends Validator<T>,
S extends Serializer<T>
> {
constructor(
private EntityClass: EntityConstructor<T>,
private validator: V,
private serializer: S
) {}
create(data: Omit<T, 'id' | 'createdAt'>): T | null {
const entity = new this.EntityClass({
...data,
id: crypto.randomUUID(),
createdAt: new Date()
} as any);
if (!this.validator.validate(entity)) {
console.error("Validation failed:", this.validator.errors);
return null;
}
return entity;
}
save(entity: T): string {
return this.serializer.serialize(entity);
}
load(data: string): T | null {
try {
const entity = this.serializer.deserialize(data);
if (this.validator.validate(entity)) {
return entity;
}
} catch (error) {
console.error("Failed to load entity:", error);
}
return null;
}
}
🔄 Transformation Constraints
Type-safe data transformations:
// 🎯 Mapper constraints
interface Mappable<TSource, TTarget> {
map(source: TSource): TTarget;
}
class TransformPipeline<TInput, TOutput> {
private transformers: Array<Mappable<any, any>> = [];
addTransformer<TIntermediate>(
transformer: Mappable<TInput, TIntermediate>
): TransformPipeline<TIntermediate, TOutput> {
this.transformers.push(transformer);
return this as any;
}
execute(input: TInput): TOutput {
return this.transformers.reduce(
(value, transformer) => transformer.map(value),
input
) as any;
}
}
// 🏗️ Type-preserving transformations
type Transform<T, K extends keyof T, V> = Omit<T, K> & { [P in K]: V };
function transformProperty<T, K extends keyof T, V>(
obj: T,
key: K,
transform: (value: T[K]) => V
): Transform<T, K, V> {
const { [key]: oldValue, ...rest } = obj;
return {
...rest,
[key]: transform(oldValue)
} as any;
}
const user = { id: 1, name: "alice", age: 30 };
const transformed = transformProperty(user, "name", (name) => name.toUpperCase());
// Type is { id: number; name: string; age: number } with uppercase name
// 🔧 Recursive transformation constraints
type DeepTransform<T, TFrom, TTo> = T extends TFrom
? TTo
: T extends object
? { [K in keyof T]: DeepTransform<T[K], TFrom, TTo> }
: T;
function deepTransform<T, TFrom, TTo>(
obj: T,
predicate: (value: any) => value is TFrom,
transform: (value: TFrom) => TTo
): DeepTransform<T, TFrom, TTo> {
if (predicate(obj)) {
return transform(obj) as any;
}
if (typeof obj === 'object' && obj !== null) {
const result: any = Array.isArray(obj) ? [] : {};
for (const key in obj) {
result[key] = deepTransform(obj[key], predicate, transform);
}
return result;
}
return obj as any;
}
// Transform all numbers to strings
const data = {
id: 1,
user: {
age: 30,
scores: [95, 87, 92]
}
};
const stringified = deepTransform(
data,
(value): value is number => typeof value === 'number',
(num) => num.toString()
);
// All numbers are now strings!
🛡️ Validation Constraints
Type-safe validation patterns:
// 🎯 Validator constraints
interface ValidationRule<T> {
validate(value: T): boolean;
message: string;
}
interface ValidatableType<T> {
[K in keyof T]: ValidationRule<T[K]>[];
}
class TypeValidator<T extends Record<string, any>> {
constructor(private rules: Partial<ValidatableType<T>>) {}
validate(obj: T): { valid: boolean; errors: Record<keyof T, string[]> } {
const errors: Record<keyof T, string[]> = {} as any;
let valid = true;
for (const key in this.rules) {
const rules = this.rules[key];
if (!rules) continue;
const fieldErrors: string[] = [];
for (const rule of rules) {
if (!rule.validate(obj[key])) {
fieldErrors.push(rule.message);
valid = false;
}
}
if (fieldErrors.length > 0) {
errors[key] = fieldErrors;
}
}
return { valid, errors };
}
}
// 🏗️ Schema-based constraints
type SchemaType =
| 'string'
| 'number'
| 'boolean'
| 'date'
| { type: 'array'; of: SchemaType }
| { type: 'object'; properties: Record<string, SchemaType> };
type SchemaToType<S extends SchemaType> =
S extends 'string' ? string :
S extends 'number' ? number :
S extends 'boolean' ? boolean :
S extends 'date' ? Date :
S extends { type: 'array'; of: infer T } ? SchemaToType<T>[] :
S extends { type: 'object'; properties: infer P } ?
{ [K in keyof P]: SchemaToType<P[K]> } :
never;
function validateSchema<S extends SchemaType>(
schema: S,
value: unknown
): value is SchemaToType<S> {
if (typeof schema === 'string') {
switch (schema) {
case 'string': return typeof value === 'string';
case 'number': return typeof value === 'number';
case 'boolean': return typeof value === 'boolean';
case 'date': return value instanceof Date;
}
}
if (schema.type === 'array') {
return Array.isArray(value) &&
value.every(item => validateSchema(schema.of, item));
}
if (schema.type === 'object') {
if (typeof value !== 'object' || value === null) return false;
for (const key in schema.properties) {
if (!validateSchema(schema.properties[key], (value as any)[key])) {
return false;
}
}
return true;
}
return false;
}
// 🔧 Type-safe form validation
interface FormField<T> {
value: T;
validators: ValidationRule<T>[];
errors: string[];
touched: boolean;
}
type FormSchema<T> = {
[K in keyof T]: FormField<T[K]>;
};
class TypedForm<T extends Record<string, any>> {
constructor(public fields: FormSchema<T>) {}
getValue(): T {
const result = {} as T;
for (const key in this.fields) {
result[key] = this.fields[key].value;
}
return result;
}
validate(): boolean {
let isValid = true;
for (const key in this.fields) {
const field = this.fields[key];
field.errors = [];
for (const validator of field.validators) {
if (!validator.validate(field.value)) {
field.errors.push(validator.message);
isValid = false;
}
}
}
return isValid;
}
setFieldValue<K extends keyof T>(key: K, value: T[K]): void {
this.fields[key].value = value;
this.fields[key].touched = true;
}
}
🎮 Hands-On Exercise
Let’s build a type-safe query language with constraints!
📝 Challenge: Type-Safe Query DSL
Create a Domain Specific Language for queries that:
- Ensures type safety throughout the query
- Supports different operators based on field types
- Provides auto-completion and type checking
- Prevents invalid queries at compile time
// Your challenge: Implement this query DSL
interface QueryDSL<T> {
where<K extends keyof T>(
field: K,
operator: OperatorFor<T[K]>,
value: T[K]
): QueryDSL<T>;
and<K extends keyof T>(
field: K,
operator: OperatorFor<T[K]>,
value: T[K]
): QueryDSL<T>;
or<K extends keyof T>(
field: K,
operator: OperatorFor<T[K]>,
value: T[K]
): QueryDSL<T>;
orderBy<K extends keyof T>(
field: K,
direction?: 'asc' | 'desc'
): QueryDSL<T>;
select<K extends keyof T>(...fields: K[]): QueryDSL<Pick<T, K>>;
build(): Query;
}
// Example usage to support:
interface User {
id: number;
name: string;
age: number;
email: string;
isActive: boolean;
createdAt: Date;
}
const query = createQuery<User>()
.where('age', 'gt', 18)
.and('isActive', 'eq', true)
.or('name', 'contains', 'John')
.orderBy('createdAt', 'desc')
.select('id', 'name', 'email')
.build();
// Type errors should occur for:
// .where('age', 'contains', 18) // 'contains' not valid for numbers
// .where('name', 'gt', 'John') // 'gt' not valid for strings
💡 Solution
Click to see the solution
// 🎯 Type-safe query DSL implementation
type StringOperator = 'eq' | 'ne' | 'contains' | 'startsWith' | 'endsWith';
type NumberOperator = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte';
type BooleanOperator = 'eq' | 'ne';
type DateOperator = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte';
type ArrayOperator = 'contains' | 'containsAny' | 'containsAll';
type OperatorFor<T> =
T extends string ? StringOperator :
T extends number ? NumberOperator :
T extends boolean ? BooleanOperator :
T extends Date ? DateOperator :
T extends any[] ? ArrayOperator :
'eq' | 'ne';
interface WhereClause {
field: string;
operator: string;
value: any;
connector?: 'and' | 'or';
}
interface OrderByClause {
field: string;
direction: 'asc' | 'desc';
}
interface Query {
where: WhereClause[];
orderBy: OrderByClause[];
select: string[];
}
// 🏗️ Query builder implementation
class QueryBuilder<T, TSelected = T> implements QueryDSL<T> {
private whereClause: WhereClause[] = [];
private orderByClause: OrderByClause[] = [];
private selectFields: string[] = [];
where<K extends keyof T>(
field: K,
operator: OperatorFor<T[K]>,
value: T[K]
): QueryDSL<T> {
this.whereClause.push({
field: String(field),
operator: String(operator),
value
});
return this;
}
and<K extends keyof T>(
field: K,
operator: OperatorFor<T[K]>,
value: T[K]
): QueryDSL<T> {
if (this.whereClause.length === 0) {
throw new Error("Cannot use 'and' without a preceding 'where' clause");
}
this.whereClause.push({
field: String(field),
operator: String(operator),
value,
connector: 'and'
});
return this;
}
or<K extends keyof T>(
field: K,
operator: OperatorFor<T[K]>,
value: T[K]
): QueryDSL<T> {
if (this.whereClause.length === 0) {
throw new Error("Cannot use 'or' without a preceding 'where' clause");
}
this.whereClause.push({
field: String(field),
operator: String(operator),
value,
connector: 'or'
});
return this;
}
orderBy<K extends keyof T>(
field: K,
direction: 'asc' | 'desc' = 'asc'
): QueryDSL<T> {
this.orderByClause.push({
field: String(field),
direction
});
return this;
}
select<K extends keyof T>(...fields: K[]): QueryDSL<Pick<T, K>> {
this.selectFields = fields.map(String);
return this as any;
}
build(): Query {
return {
where: this.whereClause,
orderBy: this.orderByClause,
select: this.selectFields.length > 0 ? this.selectFields : ['*']
};
}
}
// 🔧 Advanced query executor
class TypedQueryExecutor<T> {
constructor(private data: T[]) {}
execute(query: Query): T[] {
let result = [...this.data];
// Apply where clauses
if (query.where.length > 0) {
result = result.filter(item => {
let currentResult = true;
let previousConnector: 'and' | 'or' = 'and';
for (const clause of query.where) {
const fieldValue = (item as any)[clause.field];
const clauseResult = this.evaluateCondition(
fieldValue,
clause.operator,
clause.value
);
if (clause.connector === 'or' || previousConnector === 'or') {
currentResult = currentResult || clauseResult;
} else {
currentResult = currentResult && clauseResult;
}
previousConnector = clause.connector || 'and';
}
return currentResult;
});
}
// Apply order by
if (query.orderBy.length > 0) {
result.sort((a, b) => {
for (const orderBy of query.orderBy) {
const aValue = (a as any)[orderBy.field];
const bValue = (b as any)[orderBy.field];
let comparison = 0;
if (aValue < bValue) comparison = -1;
else if (aValue > bValue) comparison = 1;
if (comparison !== 0) {
return orderBy.direction === 'asc' ? comparison : -comparison;
}
}
return 0;
});
}
// Apply select
if (query.select[0] !== '*') {
result = result.map(item => {
const selected: any = {};
for (const field of query.select) {
selected[field] = (item as any)[field];
}
return selected;
});
}
return result;
}
private evaluateCondition(
fieldValue: any,
operator: string,
value: any
): boolean {
switch (operator) {
case 'eq': return fieldValue === value;
case 'ne': return fieldValue !== value;
case 'gt': return fieldValue > value;
case 'gte': return fieldValue >= value;
case 'lt': return fieldValue < value;
case 'lte': return fieldValue <= value;
case 'contains':
if (typeof fieldValue === 'string') {
return fieldValue.includes(value);
}
if (Array.isArray(fieldValue)) {
return fieldValue.includes(value);
}
return false;
case 'startsWith':
return typeof fieldValue === 'string' && fieldValue.startsWith(value);
case 'endsWith':
return typeof fieldValue === 'string' && fieldValue.endsWith(value);
case 'containsAny':
return Array.isArray(fieldValue) &&
value.some((v: any) => fieldValue.includes(v));
case 'containsAll':
return Array.isArray(fieldValue) &&
value.every((v: any) => fieldValue.includes(v));
default:
return false;
}
}
}
// 🎨 Factory function
function createQuery<T>(): QueryDSL<T> {
return new QueryBuilder<T>();
}
// 💫 Advanced features
interface QueryDSLAdvanced<T> extends QueryDSL<T> {
whereIn<K extends keyof T>(
field: K,
values: T[K][]
): QueryDSLAdvanced<T>;
whereBetween<K extends keyof T>(
field: K,
min: T[K],
max: T[K]
): QueryDSLAdvanced<T>;
whereNull<K extends keyof T>(
field: K
): QueryDSLAdvanced<T>;
whereNotNull<K extends keyof T>(
field: K
): QueryDSLAdvanced<T>;
groupBy<K extends keyof T>(
...fields: K[]
): QueryDSLAdvanced<T>;
having<K extends keyof T>(
field: K,
operator: OperatorFor<T[K]>,
value: T[K]
): QueryDSLAdvanced<T>;
limit(count: number): QueryDSLAdvanced<T>;
offset(count: number): QueryDSLAdvanced<T>;
}
// Test the implementation
interface User {
id: number;
name: string;
age: number;
email: string;
isActive: boolean;
createdAt: Date;
tags: string[];
}
const users: User[] = [
{
id: 1,
name: "John Doe",
age: 25,
email: "[email protected]",
isActive: true,
createdAt: new Date("2024-01-01"),
tags: ["developer", "senior"]
},
{
id: 2,
name: "Jane Smith",
age: 30,
email: "[email protected]",
isActive: false,
createdAt: new Date("2024-02-01"),
tags: ["designer", "lead"]
},
{
id: 3,
name: "Bob Johnson",
age: 35,
email: "[email protected]",
isActive: true,
createdAt: new Date("2024-03-01"),
tags: ["developer", "junior"]
}
];
// Create and execute queries
const executor = new TypedQueryExecutor(users);
const query1 = createQuery<User>()
.where('age', 'gt', 25)
.and('isActive', 'eq', true)
.orderBy('name', 'asc')
.select('id', 'name', 'email')
.build();
console.log("Query 1 results:", executor.execute(query1));
const query2 = createQuery<User>()
.where('tags', 'contains', 'developer')
.or('age', 'lt', 30)
.orderBy('createdAt', 'desc')
.build();
console.log("Query 2 results:", executor.execute(query2));
// Type errors (uncomment to see):
// createQuery<User>().where('age', 'contains', 25); // Error!
// createQuery<User>().where('name', 'gt', 'John'); // Error!
// createQuery<User>().where('isActive', 'gte', true); // Error!
console.log("\n✅ Type-safe query DSL working correctly!");
🎯 Summary
You’ve mastered generic constraints in TypeScript! 🎉 You learned how to:
- 🔒 Apply powerful constraints to control type parameters
- 🔑 Use keyof constraints for property-based type safety
- 🎭 Leverage conditional types in constraints
- 🏭 Build type-safe factories and transformations
- 🛡️ Create validation systems with proper constraints
- ✨ Design professional APIs with controlled flexibility
Generic constraints are the key to building flexible yet type-safe TypeScript code. They allow you to create powerful abstractions while maintaining the safety and developer experience TypeScript is known for!
Keep building with confident constraints! 🚀