Lesson 5.2: Generic Functions
Duration: 55 minutes
Learning Objectives
By the end of this lesson, you will be able to:
- Write generic functions with constraints
- Use multiple type parameters effectively
- Apply default type parameters
- Understand generic type inference in depth
- Create higher-order generic functions
Review: Basic Generic Functions
A quick refresher on generic function syntax:
function identity<T>(value: T): T {
return value;
}
// Usage with inference
const num = identity(42); // T inferred as number
const str = identity('hello'); // T inferred as string
// Usage with explicit type
const arr = identity<number[]>([1, 2, 3]);
Generic Constraints
Sometimes you need to limit what types can be used with a generic. Use extends to add constraints:
Basic Constraints
// T must have a length property
function logLength<T extends { length: number }>(value: T): void {
console.log(`Length: ${value.length}`);
}
logLength('hello'); // OK - strings have length
logLength([1, 2, 3]); // OK - arrays have length
logLength({ length: 5 }); // OK - object has length property
// Error: number has no length property
// logLength(42);
Constraining to Specific Types
// T must be a string or number
function formatValue<T extends string | number>(value: T): string {
return `Value: ${value}`;
}
formatValue('hello'); // OK
formatValue(42); // OK
// Error: boolean is not string | number
// formatValue(true);
Constraining to Object Types
interface HasId {
id: number;
}
function printId<T extends HasId>(item: T): void {
console.log(`ID: ${item.id}`);
}
printId({ id: 1, name: 'Alice' }); // OK
printId({ id: 2, email: 'bob@example.com' }); // OK
// Error: missing id property
// printId({ name: "Charlie" });
The keyof Constraint
A common pattern is constraining one type parameter to be a key of another:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const person = {
name: 'Alice',
age: 30,
email: 'alice@example.com',
};
const name = getProperty(person, 'name'); // string
const age = getProperty(person, 'age'); // number
const email = getProperty(person, 'email'); // string
// Error: "phone" is not a key of person
// getProperty(person, "phone");
Setting Multiple Properties
function setProperty<T, K extends keyof T>(obj: T, key: K, value: T[K]): void {
obj[key] = value;
}
const user = { name: 'Bob', age: 25 };
setProperty(user, 'name', 'Robert'); // OK
setProperty(user, 'age', 26); // OK
// Error: value type does not match
// setProperty(user, "age", "twenty-six");
Multiple Type Parameters with Constraints
You can have multiple type parameters with their own constraints:
function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
const result = merge({ name: 'Alice' }, { age: 30 });
// Type: { name: string } & { age: number }
console.log(result.name); // "Alice"
console.log(result.age); // 30
Chaining Constraints
One type parameter can depend on another:
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map((item) => item[key]);
}
const users = [
{ name: 'Alice', age: 30 },
{ name: 'Bob', age: 25 },
{ name: 'Charlie', age: 35 },
];
const names = pluck(users, 'name'); // string[]
const ages = pluck(users, 'age'); // number[]
console.log(names); // ["Alice", "Bob", "Charlie"]
console.log(ages); // [30, 25, 35]
Default Type Parameters
Like default function parameters, type parameters can have defaults:
function createArray<T = string>(length: number, value: T): T[] {
return Array(length).fill(value);
}
// Uses default type (string)
const strings = createArray(3, 'hello'); // string[]
// Explicit type overrides default
const numbers = createArray<number>(3, 42); // number[]
Practical Example: Response Wrapper
interface ApiResponse<T = unknown> {
data: T;
status: number;
message: string;
}
// Using default type
const response1: ApiResponse = {
data: { any: 'thing' },
status: 200,
message: 'OK',
};
// With specific type
const response2: ApiResponse<{ id: number; name: string }> = {
data: { id: 1, name: 'Product' },
status: 200,
message: 'OK',
};
Generic Arrow Functions
Arrow functions can also be generic:
// Regular arrow function
const identity = <T>(value: T): T => value;
// With constraint
const getLength = <T extends { length: number }>(value: T): number => {
return value.length;
};
// Multiple type parameters
const pair = <T, U>(first: T, second: U): [T, U] => [first, second];
// Usage
const num = identity(42); // number
const len = getLength([1, 2, 3]); // 3
const p = pair('hello', 42); // [string, number]
Note on JSX
In JSX files (.tsx), the <T> syntax can be confused with JSX tags. Use one of these solutions:
// Solution 1: Add a constraint
const identity = <T extends unknown>(value: T): T => value;
// Solution 2: Add a trailing comma
const identity = <T,>(value: T): T => value;
// Solution 3: Use function keyword
function identity<T>(value: T): T {
return value;
}
Higher-Order Generic Functions
Functions that return generic functions:
function createMapper<T>() {
return function <U>(arr: T[], fn: (item: T) => U): U[] {
return arr.map(fn);
};
}
const numberMapper = createMapper<number>();
const doubled = numberMapper([1, 2, 3], (n) => n * 2); // number[]
const asStrings = numberMapper([1, 2, 3], (n) => String(n)); // string[]
Factory Functions
function createFactory<T>(creator: () => T) {
return {
create(): T {
return creator();
},
createMany(count: number): T[] {
return Array(count)
.fill(null)
.map(() => creator());
},
};
}
interface User {
id: number;
name: string;
}
let nextId = 1;
const userFactory = createFactory<User>(() => ({
id: nextId++,
name: `User ${nextId}`,
}));
const user = userFactory.create(); // User
const users = userFactory.createMany(3); // User[]
Generic Callbacks
Functions that accept generic callbacks:
function transform<T, R>(value: T, transformer: (input: T) => R): R {
return transformer(value);
}
const length = transform('hello', (str) => str.length); // number
const upper = transform('hello', (str) => str.toUpperCase()); // string
const doubled = transform(21, (num) => num * 2); // number
Array Operations
function filterMap<T, R>(arr: T[], predicate: (item: T) => boolean, mapper: (item: T) => R): R[] {
return arr.filter(predicate).map(mapper);
}
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const evenSquares = filterMap(
numbers,
(n) => n % 2 === 0,
(n) => n * n
);
// [4, 16, 36, 64, 100]
Practical Examples
Example 1: Safe Object Access
function safeGet<T, K extends keyof T>(
obj: T | null | undefined,
key: K,
defaultValue: T[K]
): T[K] {
if (obj === null || obj === undefined) {
return defaultValue;
}
return obj[key];
}
const user = { name: 'Alice', age: 30 };
const nullUser: typeof user | null = null;
console.log(safeGet(user, 'name', 'Unknown')); // "Alice"
console.log(safeGet(nullUser, 'name', 'Unknown')); // "Unknown"
Example 2: Group By
function groupBy<T, K extends keyof T>(arr: T[], key: K): Record<string, T[]> {
return arr.reduce(
(groups, item) => {
const groupKey = String(item[key]);
if (!groups[groupKey]) {
groups[groupKey] = [];
}
groups[groupKey].push(item);
return groups;
},
{} as Record<string, T[]>
);
}
const people = [
{ name: 'Alice', department: 'Engineering' },
{ name: 'Bob', department: 'Marketing' },
{ name: 'Charlie', department: 'Engineering' },
{ name: 'Diana', department: 'Marketing' },
];
const byDepartment = groupBy(people, 'department');
/*
{
Engineering: [{ name: "Alice", ... }, { name: "Charlie", ... }],
Marketing: [{ name: "Bob", ... }, { name: "Diana", ... }]
}
*/
Example 3: Memoization
function memoize<T extends (...args: any[]) => any>(fn: T): T {
const cache = new Map<string, ReturnType<T>>();
return function (...args: Parameters<T>): ReturnType<T> {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key)!;
}
const result = fn(...args);
cache.set(key, result);
return result;
} as T;
}
function expensiveCalculation(n: number): number {
console.log(`Calculating for ${n}...`);
return n * n;
}
const memoized = memoize(expensiveCalculation);
console.log(memoized(5)); // Logs "Calculating for 5...", returns 25
console.log(memoized(5)); // Returns 25 (from cache, no log)
console.log(memoized(10)); // Logs "Calculating for 10...", returns 100
Example 4: Type-Safe Event Emitter
type EventHandler<T> = (data: T) => void;
function createEventEmitter<Events extends Record<string, any>>() {
const handlers: Partial<{
[K in keyof Events]: EventHandler<Events[K]>[];
}> = {};
return {
on<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>) {
if (!handlers[event]) {
handlers[event] = [];
}
handlers[event]!.push(handler);
},
emit<K extends keyof Events>(event: K, data: Events[K]) {
handlers[event]?.forEach((handler) => handler(data));
},
};
}
// Define event types
interface MyEvents {
login: { userId: number; timestamp: Date };
logout: { userId: number };
error: { message: string; code: number };
}
const emitter = createEventEmitter<MyEvents>();
emitter.on('login', (data) => {
console.log(`User ${data.userId} logged in at ${data.timestamp}`);
});
emitter.on('error', (data) => {
console.log(`Error ${data.code}: ${data.message}`);
});
emitter.emit('login', { userId: 1, timestamp: new Date() });
emitter.emit('error', { message: 'Not found', code: 404 });
Exercises
Exercise 1: Find Maximum
Create a generic function findMax that finds the maximum value in an array. Use a constraint to ensure the type has comparison operators:
// Test
console.log(findMax([3, 1, 4, 1, 5, 9])); // 9
console.log(findMax(['apple', 'zebra', 'mango'])); // "zebra"
console.log(findMax([new Date(2020, 0, 1), new Date(2023, 0, 1)])); // Date 2023
Solution
function findMax<T extends number | string | Date>(arr: T[]): T | undefined {
if (arr.length === 0) return undefined;
return arr.reduce((max, current) => {
return current > max ? current : max;
});
}
console.log(findMax([3, 1, 4, 1, 5, 9])); // 9
console.log(findMax(['apple', 'zebra', 'mango'])); // "zebra"
console.log(findMax([new Date(2020, 0, 1), new Date(2023, 0, 1)]));
// Date object for 2023
Exercise 2: Pick Properties
Create a generic function pick that creates a new object with only the specified properties:
// Test
const user = { id: 1, name: 'Alice', email: 'alice@example.com', age: 30 };
const picked = pick(user, ['name', 'email']);
console.log(picked); // { name: "Alice", email: "alice@example.com" }
Solution
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
const result = {} as Pick<T, K>;
for (const key of keys) {
result[key] = obj[key];
}
return result;
}
const user = { id: 1, name: 'Alice', email: 'alice@example.com', age: 30 };
const picked = pick(user, ['name', 'email']);
// Type: Pick<typeof user, "name" | "email">
console.log(picked); // { name: "Alice", email: "alice@example.com" }
Exercise 3: Debounce
Create a generic debounce function that delays function execution:
// Test
const log = debounce((message: string) => {
console.log(message);
}, 1000);
log('Hello'); // Will log after 1 second if no more calls
log('World'); // Cancels previous, will log after 1 second
Solution
function debounce<T extends (...args: any[]) => void>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return function (...args: Parameters<T>) {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
fn(...args);
timeoutId = null;
}, delay);
};
}
const log = debounce((message: string) => {
console.log(message);
}, 1000);
log('Hello');
log('World'); // Only "World" will be logged after 1 second
Exercise 4: Zip Arrays
Create a generic function zip that combines two arrays into an array of pairs:
// Test
const numbers = [1, 2, 3];
const letters = ['a', 'b', 'c'];
const zipped = zip(numbers, letters);
console.log(zipped); // [[1, "a"], [2, "b"], [3, "c"]]
Solution
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"]]
Key Takeaways
- Constraints limit types: Use
extendsto restrict what types can be used keyofenables property access: Constrain to valid keys of an object- Default type parameters: Provide fallbacks like
<T = string> - Arrow functions support generics: Use
<T>or<T,>syntax - Higher-order functions: Generics work well with functions that return functions
- Type inference is powerful: Let TypeScript figure out types when possible
Resources
| Resource | Type | Description |
|---|---|---|
| TypeScript Handbook: Generics | Documentation | Complete guide to generics |
| TypeScript Handbook: keyof | Documentation | Using keyof operator |
| TypeScript Deep Dive: Generics | Tutorial | Advanced generic patterns |
Next Lesson
Now that you have mastered generic functions, let us apply these concepts to classes and interfaces.