TypeScript Blitz - Day 4: Generics - Fundamentals
Write reusable type-safe functions and classes with TypeScript generics - learn generic functions, constraints, and real-world patterns
Goal
After this lesson you’ll write reusable functions and classes with generics, enabling type-safe code that works with multiple types.
Theory Part (20 min)
1. What Are Generics and Why Do We Need Them?
Problem without generics:
// Without generics - have to duplicate code or use 'any'
function getFirstNumber(arr: number[]): number {
return arr[0];
}
function getFirstString(arr: string[]): string {
return arr[0];
}
// Or lose type safety:
function getFirst(arr: any[]): any {
return arr[0]; // No type checking
}
const num = getFirst([1, 2, 3]);
num.toUpperCase(); // Compiles but crashes at runtime
Solution with generics:
// Generic function - works with any type while maintaining type safety
function getFirst<T>(arr: T[]): T {
return arr[0];
}
const num = getFirst([1, 2, 3]); // T = number
const str = getFirst(["a", "b", "c"]); // T = string
// num.toUpperCase(); // Error - TypeScript knows num is number
str.toUpperCase(); // OK - TypeScript knows str is string
Key concept: Generics are like function parameters, but for types. They create a relationship between input and output types.
2. Generic Functions - Basic Syntax
// Single type parameter
function identity<T>(value: T): T {
return value;
}
const num = identity(42); // T = number
const str = identity("hello"); // T = string
const obj = identity({ id: 1 }); // T = { id: number }
// Explicit type argument (when TypeScript can't infer)
const result = identity<string>("hello");
// Array operations with generics
function reverse<T>(arr: T[]): T[] {
return arr.reverse();
}
const nums = reverse([1, 2, 3]); // number[]
const strs = reverse(["a", "b", "c"]); // string[]
// Generic with specific operations
function getLength<T>(arg: T[]): number {
return arg.length; // OK - arrays have length
}
// This would error:
// function getLength<T>(arg: T): number {
// return arg.length; // Error - T might not have length
// }
3. Multiple Type Parameters
// Two type parameters
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}
const p1 = pair(1, "hello"); // [number, string]
const p2 = pair(true, { id: 1 }); // [boolean, { id: number }]
// Practical example: key-value store
function createMap<K, V>(key: K, value: V): Map<K, V> {
const map = new Map<K, V>();
map.set(key, value);
return map;
}
const userMap = createMap("userId", { name: "John", age: 30 });
// Map<string, { name: string; age: number }>
// Type parameters can reference each other
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "John", age: 30, email: "john@example.com" };
const name = getProperty(user, "name"); // string
const age = getProperty(user, "age"); // number
// getProperty(user, "invalid"); // Error - "invalid" is not a key
4. Generic Constraints - Extending Types
// Constraint: T must have a length property
interface HasLength {
length: number;
}
function logLength<T extends HasLength>(arg: T): void {
console.log(arg.length); // OK - T guaranteed to have length
}
logLength("hello"); // string has length
logLength([1, 2, 3]); // array has length
logLength({ length: 10 }); // object with length
// logLength(123); // Error - number has no length
// Constraint with extends keyof
function extractProperty<T, K extends keyof T>(
obj: T,
key: K
): T[K] {
return obj[key];
}
const product = { id: 1, name: "Laptop", price: 999 };
const productName = extractProperty(product, "name"); // string
const productPrice = extractProperty(product, "price"); // number
// extractProperty(product, "invalid"); // Error
// Multiple constraints
interface Named {
name: string;
}
interface Aged {
age: number;
}
function printPerson<T extends Named & Aged>(person: T): void {
console.log(`${person.name} is ${person.age} years old`);
}
printPerson({ name: "John", age: 30 }); // OK
// printPerson({ name: "John" }); // Error - missing age
// printPerson({ age: 30 }); // Error - missing name
5. Generic Classes
// Generic class - data structure
class Box<T> {
private value: T;
constructor(value: T) {
this.value = value;
}
getValue(): T {
return this.value;
}
setValue(value: T): void {
this.value = value;
}
}
const numberBox = new Box(123); // Box<number>
const stringBox = new Box("hello"); // Box<string>
numberBox.setValue(456); // OK
// numberBox.setValue("text"); // Error - expects number
// Generic class with methods
class Collection<T> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
get(index: number): T | undefined {
return this.items[index];
}
filter(predicate: (item: T) => boolean): T[] {
return this.items.filter(predicate);
}
map<U>(fn: (item: T) => U): U[] {
return this.items.map(fn);
}
}
const numbers = new Collection<number>();
numbers.add(1);
numbers.add(2);
numbers.add(3);
const doubled = numbers.map(n => n * 2); // number[]
const strings = numbers.map(n => n.toString()); // string[]
6. Generic Interfaces
// Generic interface
interface Result<T> {
success: boolean;
data: T;
error?: string;
}
const userResult: Result<User> = {
success: true,
data: { id: 1, name: "John", email: "john@example.com" }
};
const numberResult: Result<number> = {
success: false,
data: 0,
error: "Failed to fetch"
};
// Generic interface for API responses
interface ApiResponse<T> {
status: number;
data: T;
metadata: {
timestamp: Date;
requestId: string;
};
}
function handleResponse<T>(response: ApiResponse<T>): T {
if (response.status === 200) {
return response.data;
}
throw new Error("Request failed");
}
// Generic interface with methods
interface Repository<T> {
findById(id: string): Promise<T | null>;
findAll(): Promise<T[]>;
create(item: Omit<T, "id">): Promise<T>;
update(id: string, item: Partial<T>): Promise<T>;
delete(id: string): Promise<void>;
}
class UserRepository implements Repository<User> {
async findById(id: string): Promise<User | null> {
// Implementation
return null;
}
async findAll(): Promise<User[]> {
// Implementation
return [];
}
async create(item: Omit<User, "id">): Promise<User> {
// Implementation
return { id: "1", ...item };
}
async update(id: string, item: Partial<User>): Promise<User> {
// Implementation
return { id, name: "", email: "", ...item };
}
async delete(id: string): Promise<void> {
// Implementation
}
}
7. Default Generic Types
// Generic with default type
interface Response<T = unknown> {
data: T;
status: number;
}
// Can use without specifying type
const response1: Response = {
data: "anything", // T = unknown
status: 200
};
// Or specify type
const response2: Response<User> = {
data: { id: 1, name: "John", email: "john@example.com" },
status: 200
};
// Default type in functions
function fetchData<T = any>(url: string): Promise<T> {
return fetch(url).then(r => r.json());
}
// Uses default (any)
const data1 = await fetchData("/api/data");
// Specifies type
const data2 = await fetchData<User>("/api/users");
// Multiple defaults
interface Config<T = string, U = number> {
value: T;
count: U;
}
const config1: Config = { value: "hello", count: 5 };
const config2: Config<boolean> = { value: true, count: 5 };
const config3: Config<boolean, string> = { value: true, count: "many" };
Practice Part (35 min)
Exercise 1: Generic API Client (15 min)
Create a generic API client that handles different resource types.
// TODO: Create generic ApiClient class
// Requirements:
// 1. Generic type parameter T for resource type
// 2. Methods: get(id: string), list(), create(data), update(id, data), delete(id)
// 3. All methods return Promise with correct types
class ApiClient<T> {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
// TODO: Implement methods
// async get(id: string): Promise<T> {}
// async list(): Promise<T[]> {}
// async create(data: Omit<T, "id">): Promise<T> {}
// async update(id: string, data: Partial<T>): Promise<T> {}
// async delete(id: string): Promise<void> {}
}
// Test with different types
interface User {
id: string;
name: string;
email: string;
}
interface Post {
id: string;
title: string;
content: string;
authorId: string;
}
// const userClient = new ApiClient<User>("/api/users");
// const user = await userClient.get("123"); // User
// const users = await userClient.list(); // User[]
// await userClient.create({ name: "John", email: "john@example.com" });
// const postClient = new ApiClient<Post>("/api/posts");
// const post = await postClient.get("456"); // Post
Solution:
Click to see solution
class ApiClient<T extends { id: string }> {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async get(id: string): Promise<T> {
const response = await fetch(`${this.baseUrl}/${id}`);
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.statusText}`);
}
return response.json();
}
async list(): Promise<T[]> {
const response = await fetch(this.baseUrl);
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.statusText}`);
}
return response.json();
}
async create(data: Omit<T, "id">): Promise<T> {
const response = await fetch(this.baseUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error(`Failed to create: ${response.statusText}`);
}
return response.json();
}
async update(id: string, data: Partial<T>): Promise<T> {
const response = await fetch(`${this.baseUrl}/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error(`Failed to update: ${response.statusText}`);
}
return response.json();
}
async delete(id: string): Promise<void> {
const response = await fetch(`${this.baseUrl}/${id}`, {
method: "DELETE"
});
if (!response.ok) {
throw new Error(`Failed to delete: ${response.statusText}`);
}
}
}
// Usage
interface User {
id: string;
name: string;
email: string;
}
interface Post {
id: string;
title: string;
content: string;
authorId: string;
}
const userClient = new ApiClient<User>("/api/users");
const postClient = new ApiClient<Post>("/api/posts");
// Type-safe operations
async function example() {
const user = await userClient.get("123"); // User
console.log(user.name); // OK
const users = await userClient.list(); // User[]
users.forEach(u => console.log(u.email)); // OK
const newUser = await userClient.create({
name: "John",
email: "john@example.com"
// id is omitted - Omit<User, "id">
});
await userClient.update("123", {
name: "Jane"
// Partial<User> - all fields optional
});
}
Exercise 2: Generic State Management (12 min)
Build a type-safe state store with getters and setters.
// TODO: Create generic Store class for state management
// Requirements:
// 1. Generic type parameter T for state shape
// 2. get<K extends keyof T>(key: K): T[K] - get single value
// 3. set<K extends keyof T>(key: K, value: T[K]): void - set single value
// 4. getState(): T - get entire state
// 5. setState(newState: Partial<T>): void - update multiple values
// 6. subscribe(callback: (state: T) => void): () => void - listen to changes
class Store<T> {
private state: T;
private listeners: Array<(state: T) => void> = [];
constructor(initialState: T) {
this.state = initialState;
}
// TODO: Implement methods
}
// Test with application state
interface AppState {
user: {
id: string;
name: string;
} | null;
theme: "light" | "dark";
notifications: number;
}
// const store = new Store<AppState>({
// user: null,
// theme: "light",
// notifications: 0
// });
// const theme = store.get("theme"); // "light" | "dark"
// store.set("theme", "dark"); // OK
// store.set("notifications", 5); // OK
// // store.set("theme", "blue"); // Error - invalid value
// // store.set("invalid", "value"); // Error - invalid key
// const unsubscribe = store.subscribe((state) => {
// console.log("State changed:", state);
// });
Solution:
Click to see solution
class Store<T extends Record<string, any>> {
private state: T;
private listeners: Array<(state: T) => void> = [];
constructor(initialState: T) {
this.state = { ...initialState };
}
get<K extends keyof T>(key: K): T[K] {
return this.state[key];
}
set<K extends keyof T>(key: K, value: T[K]): void {
this.state[key] = value;
this.notify();
}
getState(): T {
return { ...this.state };
}
setState(newState: Partial<T>): void {
this.state = { ...this.state, ...newState };
this.notify();
}
subscribe(callback: (state: T) => void): () => void {
this.listeners.push(callback);
// Return unsubscribe function
return () => {
this.listeners = this.listeners.filter(listener => listener !== callback);
};
}
private notify(): void {
this.listeners.forEach(listener => listener(this.state));
}
}
// Usage
interface AppState {
user: {
id: string;
name: string;
} | null;
theme: "light" | "dark";
notifications: number;
}
const store = new Store<AppState>({
user: null,
theme: "light",
notifications: 0
});
// Type-safe getters
const theme = store.get("theme"); // "light" | "dark"
const notifications = store.get("notifications"); // number
// Type-safe setters
store.set("theme", "dark"); // OK
store.set("notifications", 5); // OK
// store.set("theme", "blue"); // Error
// store.set("invalid", "value"); // Error
// Subscribe to changes
const unsubscribe = store.subscribe((state) => {
console.log("Theme:", state.theme);
console.log("Notifications:", state.notifications);
});
// Update multiple values
store.setState({
user: { id: "123", name: "John" },
notifications: 10
});
// Unsubscribe
unsubscribe();
Exercise 3: Generic Array Utilities (8 min)
Implement generic utility functions for arrays.
// TODO: Implement these generic utilities
// 1. first - get first element (or undefined)
function first<T>(arr: T[]): T | undefined {
// TODO
}
// 2. last - get last element (or undefined)
function last<T>(arr: T[]): T | undefined {
// TODO
}
// 3. chunk - split array into chunks of specified size
function chunk<T>(arr: T[], size: number): T[][] {
// TODO
}
// 4. unique - remove duplicates (by comparison or key function)
function unique<T>(arr: T[]): T[];
function unique<T, K>(arr: T[], keyFn: (item: T) => K): T[];
function unique<T, K>(arr: T[], keyFn?: (item: T) => K): T[] {
// TODO
}
// 5. partition - split array into two groups based on predicate
function partition<T>(
arr: T[],
predicate: (item: T) => boolean
): [T[], T[]] {
// TODO
}
// Tests:
// const numbers = [1, 2, 3, 4, 5];
// console.log(first(numbers)); // 1
// console.log(last(numbers)); // 5
// console.log(chunk(numbers, 2)); // [[1, 2], [3, 4], [5]]
// const items = [1, 2, 2, 3, 3, 3];
// console.log(unique(items)); // [1, 2, 3]
// const users = [
// { id: 1, name: "John" },
// { id: 2, name: "Jane" },
// { id: 1, name: "John Doe" }
// ];
// console.log(unique(users, u => u.id)); // First two users
// const [even, odd] = partition(numbers, n => n % 2 === 0);
// console.log(even); // [2, 4]
// console.log(odd); // [1, 3, 5]
Solution:
Click to see solution
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
function last<T>(arr: T[]): T | undefined {
return arr[arr.length - 1];
}
function chunk<T>(arr: T[], size: number): T[][] {
const result: T[][] = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
function unique<T>(arr: T[]): T[];
function unique<T, K>(arr: T[], keyFn: (item: T) => K): T[];
function unique<T, K>(arr: T[], keyFn?: (item: T) => K): T[] {
if (!keyFn) {
return Array.from(new Set(arr));
}
const seen = new Set<K>();
const result: T[] = [];
for (const item of arr) {
const key = keyFn(item);
if (!seen.has(key)) {
seen.add(key);
result.push(item);
}
}
return result;
}
function partition<T>(
arr: T[],
predicate: (item: T) => boolean
): [T[], T[]] {
const passed: T[] = [];
const failed: T[] = [];
for (const item of arr) {
if (predicate(item)) {
passed.push(item);
} else {
failed.push(item);
}
}
return [passed, failed];
}
// Tests
const numbers = [1, 2, 3, 4, 5];
console.log(first(numbers)); // 1
console.log(last(numbers)); // 5
console.log(chunk(numbers, 2)); // [[1, 2], [3, 4], [5]]
const items = [1, 2, 2, 3, 3, 3];
console.log(unique(items)); // [1, 2, 3]
interface User {
id: number;
name: string;
}
const users: User[] = [
{ id: 1, name: "John" },
{ id: 2, name: "Jane" },
{ id: 1, name: "John Doe" }
];
console.log(unique(users, u => u.id)); // [{ id: 1, ... }, { id: 2, ... }]
const [even, odd] = partition(numbers, n => n % 2 === 0);
console.log(even); // [2, 4]
console.log(odd); // [1, 3, 5]
Quick Review - Key Concepts
1. What are generics and why use them?
Answer
Generics are type parameters that allow creating reusable code that works with multiple types while maintaining type safety.
Without generics:
function getFirstNumber(arr: number[]): number { return arr[0]; }
function getFirstString(arr: string[]): string { return arr[0]; }
// Or lose type safety with 'any'
With generics:
function getFirst<T>(arr: T[]): T {
return arr[0];
}
Benefits:
- Code reusability - write once, works with any type
- Type safety - no runtime type errors
- Better IDE support - autocomplete and type checking
- Self-documenting - type relationships are clear
When to use:
- Data structures (arrays, maps, sets)
- API clients and repositories
- Utility functions that work with any type
- When you need type relationship between input and output
Generics are like function parameters but for types - they make code flexible without sacrificing safety.
2. What is the difference between and ?
Answer
Unconstrained:
function identity<T>(value: T): T {
return value;
}
identity(123); // OK - T = number
identity("hello"); // OK - T = string
Constrained:
interface HasLength {
length: number;
}
function logLength<T extends HasLength>(arg: T): void {
console.log(arg.length); // OK - T guaranteed to have length
}
logLength("hello"); // OK - string has length
logLength([1, 2, 3]); // OK - array has length
// logLength(123); // Error - number has no length
Common constraints:
- extends keyof - must be a key of an object
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
- extends interface - must implement interface
interface Named { name: string; }
function greet<T extends Named>(obj: T): string {
return `Hello, ${obj.name}`;
}
- Multiple constraints
function process<T extends Named & Aged>(person: T) {
// T must have both name and age
}
Constraints allow using specific properties or methods on the generic type.
3. How do multiple type parameters work?
Answer
Multiple type parameters allow different parts of a function or class to have independent types.
Basic syntax:
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}
const result = pair(123, "hello"); // [number, string]
Type parameters can reference each other:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "John", age: 30 };
const name = getProperty(user, "name"); // string
const age = getProperty(user, "age"); // number
In classes:
class KeyValueStore<K, V> {
private store = new Map<K, V>();
set(key: K, value: V): void {
this.store.set(key, value);
}
get(key: K): V | undefined {
return this.store.get(key);
}
}
const userStore = new KeyValueStore<string, User>();
Common patterns:
- Key-Value pairs
- Input-Output transformation
- Multiple related types
Each type parameter is independent unless explicitly constrained.
4. What is keyof and how does it work with generics?
Answer
keyof is an operator that creates a union of all property keys of a type.
Basic usage:
interface User {
id: number;
name: string;
email: string;
}
type UserKeys = keyof User; // "id" | "name" | "email"
With generics - type-safe property access:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: "John", email: "john@example.com" };
const name = getProperty(user, "name"); // string
const id = getProperty(user, "id"); // number
// getProperty(user, "invalid"); // Error
How it works:
- K extends keyof T - K must be one of T’s keys
- T[K] - indexed access type, gets the type of property K in T
- TypeScript narrows types based on which key you pass
Practical examples:
Type-safe setters:
function setProperty<T, K extends keyof T>(
obj: T,
key: K,
value: T[K]
): void {
obj[key] = value;
}
keyof with generics enables type-safe dynamic property access.
5. When should you use generic classes vs generic functions?
Answer
Generic functions - when type is determined by function call
Generic classes - when type is determined at instantiation and used across multiple methods
Use generic functions when:
One-off operations:
function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
return arr.map(fn);
}
Type varies per call:
function identity<T>(value: T): T {
return value;
}
identity(123); // number
identity("hello"); // string
Use generic classes when:
State that persists across operations:
class Collection<T> {
private items: T[] = [];
add(item: T): void { this.items.push(item); }
get(index: number): T | undefined { return this.items[index]; }
}
const numbers = new Collection<number>();
Multiple methods sharing the same type:
class ApiClient<T> {
async get(id: string): Promise<T> { /* ... */ }
async list(): Promise<T[]> { /* ... */ }
async create(data: Omit<T, "id">): Promise<T> { /* ... */ }
}
Choose based on whether the type relationship is per-call or per-instance.
Checklist - What You Should Know After Day 4
- Understand what generics are and why they’re needed
- Write generic functions with single type parameter
- Use multiple type parameters
- Apply generic constraints with extends
- Use keyof with generics for type-safe property access
- Create generic classes
- Define generic interfaces
- Use default generic types
- Decide when to use generic functions vs classes
Quick Reference Card
// Basic generic function
function identity<T>(value: T): T {
return value;
}
// Multiple type parameters
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}
// Generic constraint
function getLength<T extends { length: number }>(arg: T): number {
return arg.length;
}
// keyof constraint
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
// Generic class
class Box<T> {
constructor(private value: T) {}
getValue(): T { return this.value; }
}
// Generic interface
interface Result<T> {
success: boolean;
data: T;
}
// Default generic type
interface Response<T = unknown> {
data: T;
status: number;
}
Tomorrow: Utility Types - master the built-in type transformations