Prerequisites
- ✅ Understanding of TypeScript classes and methods
- 📚 Basic knowledge of object-oriented programming
- 🔧 Familiarity with the 'this' keyword
What you'll learn
- 🎯 Master method chaining techniques in TypeScript
- 🛠️ Build fluent interfaces for better API design
- 💡 Understand the builder pattern and its applications
- 🚀 Create expressive, self-documenting code
🎯 Introduction
Welcome to the world of fluent interfaces and method chaining! 🎉 Have you ever used jQuery or modern JavaScript libraries and loved how naturally the code flows? That’s the magic of method chaining! Today, we’ll learn how to create APIs that read like sentences and make coding feel like writing poetry. 🎨
Imagine writing code that looks like this:
order.addItem("Pizza").withSize("Large").addTopping("Mushrooms").addTopping("Olives").checkout();
Beautiful, isn’t it? Let’s learn how to build such elegant interfaces! 🌟
📚 Understanding Method Chaining
🔗 What is Method Chaining?
Method chaining is a technique where each method returns the object itself (this
), allowing multiple method calls to be linked together in a single statement:
// Without method chaining 😕
const calculator = new Calculator();
calculator.add(5);
calculator.multiply(3);
calculator.subtract(2);
const result = calculator.getResult();
// With method chaining 😍
const result = new Calculator()
.add(5)
.multiply(3)
.subtract(2)
.getResult();
🌊 The Fluent Interface Pattern
A fluent interface is an API design pattern that uses method chaining to create code that flows naturally:
class MessageBuilder {
private message: string = "";
private recipient: string = "";
private subject: string = "";
// Each method returns 'this' for chaining 🔗
to(recipient: string): this {
this.recipient = recipient;
return this;
}
withSubject(subject: string): this {
this.subject = subject;
return this;
}
write(content: string): this {
this.message = content;
return this;
}
send(): void {
console.log(`
📧 Sending Email:
To: ${this.recipient}
Subject: ${this.subject}
Message: ${this.message}
`);
}
}
// Fluent usage - reads like English! 📖
new MessageBuilder()
.to("[email protected]")
.withSubject("Hello from TypeScript!")
.write("Method chaining is awesome!")
.send();
🔧 Basic Syntax and Usage
🎯 The Key: Returning ‘this’
The secret to method chaining is simple: return this
from your methods!
class Pizza {
private size: string = "medium";
private toppings: string[] = [];
private crust: string = "regular";
// Basic chaining methods 🍕
setSize(size: string): this {
this.size = size;
return this; // ← The magic happens here! ✨
}
addTopping(topping: string): this {
this.toppings.push(topping);
return this;
}
setCrust(crust: string): this {
this.crust = crust;
return this;
}
// Terminal method - doesn't return 'this' 🛑
order(): string {
return `🍕 Ordered: ${this.size} pizza with ${this.crust} crust and ${this.toppings.join(", ")}`;
}
}
// Build your pizza! 🍕
const myOrder = new Pizza()
.setSize("large")
.setCrust("thin")
.addTopping("pepperoni")
.addTopping("mushrooms")
.addTopping("extra cheese")
.order();
console.log(myOrder);
🏗️ Different Chaining Patterns
// Pattern 1: Configuration Chain 🔧
class ServerConfig {
private config = {
host: "localhost",
port: 3000,
ssl: false,
cors: false
};
host(value: string): this {
this.config.host = value;
return this;
}
port(value: number): this {
this.config.port = value;
return this;
}
enableSSL(): this {
this.config.ssl = true;
return this;
}
enableCORS(): this {
this.config.cors = true;
return this;
}
build(): Readonly<typeof this.config> {
return Object.freeze({...this.config});
}
}
const config = new ServerConfig()
.host("api.example.com")
.port(443)
.enableSSL()
.enableCORS()
.build();
// Pattern 2: Action Chain 🎬
class Animation {
private element: string;
private timeline: Array<{action: string, value: any}> = [];
constructor(element: string) {
this.element = element;
}
moveTo(x: number, y: number): this {
this.timeline.push({action: "move", value: {x, y}});
return this;
}
scale(factor: number): this {
this.timeline.push({action: "scale", value: factor});
return this;
}
rotate(degrees: number): this {
this.timeline.push({action: "rotate", value: degrees});
return this;
}
fadeIn(duration: number = 1000): this {
this.timeline.push({action: "fadeIn", value: duration});
return this;
}
play(): void {
console.log(`🎬 Playing animation for ${this.element}:`);
this.timeline.forEach((step, index) => {
console.log(` ${index + 1}. ${step.action}: ${JSON.stringify(step.value)}`);
});
}
}
new Animation("#hero-element")
.fadeIn(500)
.moveTo(100, 200)
.scale(1.5)
.rotate(360)
.play();
💡 Practical Examples
🗄️ Example 1: SQL Query Builder
Let’s build a fluent SQL query builder:
class QueryBuilder {
private query = {
select: [] as string[],
from: "",
joins: [] as string[],
where: [] as string[],
groupBy: [] as string[],
having: [] as string[],
orderBy: [] as string[],
limit: null as number | null,
offset: null as number | null
};
// SELECT clause 📋
select(...columns: string[]): this {
this.query.select.push(...columns);
return this;
}
// FROM clause 📍
from(table: string): this {
this.query.from = table;
return this;
}
// JOIN clauses 🔗
join(table: string, condition: string): this {
this.query.joins.push(`JOIN ${table} ON ${condition}`);
return this;
}
leftJoin(table: string, condition: string): this {
this.query.joins.push(`LEFT JOIN ${table} ON ${condition}`);
return this;
}
// WHERE clause 🔍
where(condition: string): this {
this.query.where.push(condition);
return this;
}
andWhere(condition: string): this {
if (this.query.where.length === 0) {
throw new Error("🚫 Cannot use andWhere without where!");
}
this.query.where.push(`AND ${condition}`);
return this;
}
orWhere(condition: string): this {
if (this.query.where.length === 0) {
throw new Error("🚫 Cannot use orWhere without where!");
}
this.query.where.push(`OR ${condition}`);
return this;
}
// GROUP BY clause 📊
groupBy(...columns: string[]): this {
this.query.groupBy.push(...columns);
return this;
}
having(condition: string): this {
this.query.having.push(condition);
return this;
}
// ORDER BY clause 🔢
orderBy(column: string, direction: "ASC" | "DESC" = "ASC"): this {
this.query.orderBy.push(`${column} ${direction}`);
return this;
}
// LIMIT and OFFSET 📏
limit(count: number): this {
this.query.limit = count;
return this;
}
offset(count: number): this {
this.query.offset = count;
return this;
}
// Build the final query 🏗️
build(): string {
if (this.query.select.length === 0) {
throw new Error("🚫 SELECT clause is required!");
}
if (!this.query.from) {
throw new Error("🚫 FROM clause is required!");
}
let sql = `SELECT ${this.query.select.join(", ")}\nFROM ${this.query.from}`;
if (this.query.joins.length > 0) {
sql += "\n" + this.query.joins.join("\n");
}
if (this.query.where.length > 0) {
sql += "\nWHERE " + this.query.where.join(" ");
}
if (this.query.groupBy.length > 0) {
sql += "\nGROUP BY " + this.query.groupBy.join(", ");
}
if (this.query.having.length > 0) {
sql += "\nHAVING " + this.query.having.join(" AND ");
}
if (this.query.orderBy.length > 0) {
sql += "\nORDER BY " + this.query.orderBy.join(", ");
}
if (this.query.limit !== null) {
sql += `\nLIMIT ${this.query.limit}`;
}
if (this.query.offset !== null) {
sql += `\nOFFSET ${this.query.offset}`;
}
return sql;
}
// Execute the query (mock) 🚀
execute(): void {
const sql = this.build();
console.log("🔍 Executing SQL Query:\n" + sql);
}
}
// Complex query example 🌟
const query = new QueryBuilder()
.select("u.id", "u.name", "COUNT(o.id) as order_count", "SUM(o.total) as total_spent")
.from("users u")
.leftJoin("orders o", "u.id = o.user_id")
.where("u.status = 'active'")
.andWhere("u.created_at > '2024-01-01'")
.groupBy("u.id", "u.name")
.having("COUNT(o.id) > 5")
.orderBy("total_spent", "DESC")
.limit(10)
.build();
console.log(query);
🎮 Example 2: Game Character Builder
Build complex game characters with a fluent interface:
class CharacterBuilder {
private character = {
name: "",
class: "",
race: "",
stats: {
strength: 10,
agility: 10,
intelligence: 10,
vitality: 10
},
skills: [] as string[],
equipment: {
weapon: null as string | null,
armor: null as string | null,
accessory: null as string | null
},
traits: [] as string[]
};
// Basic info 📝
named(name: string): this {
this.character.name = name;
return this;
}
asA(characterClass: string): this {
this.character.class = characterClass;
this.applyClassBonus(characterClass);
return this;
}
ofRace(race: string): this {
this.character.race = race;
this.applyRaceBonus(race);
return this;
}
// Stats configuration 💪
withStrength(value: number): this {
this.character.stats.strength = value;
return this;
}
withAgility(value: number): this {
this.character.stats.agility = value;
return this;
}
withIntelligence(value: number): this {
this.character.stats.intelligence = value;
return this;
}
withVitality(value: number): this {
this.character.stats.vitality = value;
return this;
}
// Bulk stat setter 📊
withStats(str: number, agi: number, int: number, vit: number): this {
this.character.stats = { strength: str, agility: agi, intelligence: int, vitality: vit };
return this;
}
// Skills 🎯
learns(skill: string): this {
if (!this.character.skills.includes(skill)) {
this.character.skills.push(skill);
}
return this;
}
// Equipment ⚔️
equips(slot: "weapon" | "armor" | "accessory", item: string): this {
this.character.equipment[slot] = item;
return this;
}
wielding(weapon: string): this {
return this.equips("weapon", weapon);
}
wearing(armor: string): this {
return this.equips("armor", armor);
}
// Traits 🌟
withTrait(trait: string): this {
if (!this.character.traits.includes(trait)) {
this.character.traits.push(trait);
}
return this;
}
// Private helper methods 🔧
private applyClassBonus(characterClass: string): void {
switch (characterClass.toLowerCase()) {
case "warrior":
this.character.stats.strength += 5;
this.character.stats.vitality += 3;
this.learns("Heavy Strike").learns("Shield Bash");
break;
case "mage":
this.character.stats.intelligence += 8;
this.learns("Fireball").learns("Frost Bolt");
break;
case "rogue":
this.character.stats.agility += 6;
this.character.stats.strength += 2;
this.learns("Stealth").learns("Backstab");
break;
}
}
private applyRaceBonus(race: string): void {
switch (race.toLowerCase()) {
case "elf":
this.character.stats.agility += 2;
this.character.stats.intelligence += 1;
this.withTrait("Night Vision");
break;
case "dwarf":
this.character.stats.strength += 2;
this.character.stats.vitality += 2;
this.withTrait("Stone Resistance");
break;
case "human":
// Humans are versatile - small bonus to all
this.character.stats.strength += 1;
this.character.stats.agility += 1;
this.character.stats.intelligence += 1;
this.character.stats.vitality += 1;
this.withTrait("Adaptable");
break;
}
}
// Build the character 🏗️
build(): GameCharacter {
if (!this.character.name || !this.character.class || !this.character.race) {
throw new Error("🚫 Character must have name, class, and race!");
}
return new GameCharacter(this.character);
}
}
class GameCharacter {
constructor(private data: any) {}
display(): void {
console.log(`
🎮 Character Sheet
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Name: ${this.data.name}
Class: ${this.data.class}
Race: ${this.data.race}
📊 Stats:
STR: ${this.data.stats.strength}
AGI: ${this.data.stats.agility}
INT: ${this.data.stats.intelligence}
VIT: ${this.data.stats.vitality}
🎯 Skills: ${this.data.skills.join(", ")}
🌟 Traits: ${this.data.traits.join(", ")}
⚔️ Equipment:
Weapon: ${this.data.equipment.weapon || "None"}
Armor: ${this.data.equipment.armor || "None"}
Accessory: ${this.data.equipment.accessory || "None"}
`);
}
}
// Create complex characters fluently! 🌟
const hero = new CharacterBuilder()
.named("Aragorn")
.asA("Warrior")
.ofRace("Human")
.withStrength(18)
.withVitality(16)
.wielding("Legendary Sword of Kings")
.wearing("Mithril Chainmail")
.learns("Whirlwind")
.learns("Battle Cry")
.withTrait("Leadership")
.withTrait("Brave")
.build();
hero.display();
📊 Example 3: Data Pipeline Builder
Create a fluent data processing pipeline:
class DataPipeline<T> {
private data: T[];
private transformations: Array<(data: T[]) => T[]> = [];
private filters: Array<(item: T) => boolean> = [];
constructor(initialData: T[]) {
this.data = [...initialData];
}
// Filtering operations 🔍
filter(predicate: (item: T) => boolean): this {
this.filters.push(predicate);
return this;
}
where(key: keyof T, value: any): this {
return this.filter(item => item[key] === value);
}
whereNot(key: keyof T, value: any): this {
return this.filter(item => item[key] !== value);
}
whereBetween(key: keyof T, min: any, max: any): this {
return this.filter(item => item[key] >= min && item[key] <= max);
}
// Transformation operations 🔄
map<U>(transformer: (item: T) => U): DataPipeline<U> {
const processed = this.process();
return new DataPipeline(processed.map(transformer));
}
// Sorting operations 🔢
sortBy(key: keyof T, order: "asc" | "desc" = "asc"): this {
this.transformations.push((data) => {
return [...data].sort((a, b) => {
if (a[key] < b[key]) return order === "asc" ? -1 : 1;
if (a[key] > b[key]) return order === "asc" ? 1 : -1;
return 0;
});
});
return this;
}
// Limiting operations 📏
take(count: number): this {
this.transformations.push(data => data.slice(0, count));
return this;
}
skip(count: number): this {
this.transformations.push(data => data.slice(count));
return this;
}
// Aggregation operations 📊
groupBy<K extends keyof T>(key: K): Map<T[K], T[]> {
const processed = this.process();
const groups = new Map<T[K], T[]>();
processed.forEach(item => {
const groupKey = item[key];
if (!groups.has(groupKey)) {
groups.set(groupKey, []);
}
groups.get(groupKey)!.push(item);
});
return groups;
}
// Terminal operations 🏁
private process(): T[] {
let result = [...this.data];
// Apply filters
this.filters.forEach(filter => {
result = result.filter(filter);
});
// Apply transformations
this.transformations.forEach(transform => {
result = transform(result);
});
return result;
}
toArray(): T[] {
return this.process();
}
first(): T | undefined {
return this.process()[0];
}
last(): T | undefined {
const processed = this.process();
return processed[processed.length - 1];
}
count(): number {
return this.process().length;
}
// Statistical operations 📈
sum(key: keyof T): number {
return this.process().reduce((acc, item) => acc + Number(item[key]), 0);
}
average(key: keyof T): number {
const processed = this.process();
if (processed.length === 0) return 0;
return this.sum(key) / processed.length;
}
// Debugging helper 🐛
debug(label?: string): this {
console.log(`🔍 Debug${label ? ` - ${label}` : ""}: `, this.process());
return this;
}
}
// Example usage with complex data 🌟
interface Sale {
id: number;
product: string;
category: string;
price: number;
quantity: number;
date: Date;
region: string;
}
const sales: Sale[] = [
{ id: 1, product: "Laptop", category: "Electronics", price: 999, quantity: 2, date: new Date("2024-01-15"), region: "North" },
{ id: 2, product: "Mouse", category: "Electronics", price: 29, quantity: 5, date: new Date("2024-01-16"), region: "South" },
{ id: 3, product: "Desk", category: "Furniture", price: 299, quantity: 1, date: new Date("2024-01-17"), region: "North" },
{ id: 4, product: "Chair", category: "Furniture", price: 199, quantity: 3, date: new Date("2024-01-18"), region: "East" },
{ id: 5, product: "Monitor", category: "Electronics", price: 399, quantity: 2, date: new Date("2024-01-19"), region: "West" },
];
// Complex data processing pipeline 🚀
const topElectronics = new DataPipeline(sales)
.where("category", "Electronics")
.filter(sale => sale.price > 50)
.sortBy("price", "desc")
.take(3)
.debug("Top 3 Electronics")
.toArray();
// Calculate regional statistics 📊
const northernSales = new DataPipeline(sales)
.where("region", "North")
.sum("quantity");
console.log(`Total Northern Sales Quantity: ${northernSales}`);
🚀 Advanced Concepts
🔄 Conditional Chaining
Add methods that conditionally modify the chain:
class ConditionalBuilder {
private config = {
features: new Set<string>(),
settings: new Map<string, any>()
};
// Conditional method execution 🔀
when(condition: boolean, callback: (builder: this) => void): this {
if (condition) {
callback(this);
}
return this;
}
// Alternative conditional pattern 🎭
if(condition: boolean): ConditionalMethods {
return {
then: (callback: (builder: this) => void) => {
if (condition) callback(this);
return this;
},
else: (callback: (builder: this) => void) => {
if (!condition) callback(this);
return this;
}
};
}
enableFeature(feature: string): this {
this.config.features.add(feature);
return this;
}
setSetting(key: string, value: any): this {
this.config.settings.set(key, value);
return this;
}
build(): Readonly<typeof this.config> {
return Object.freeze({
features: new Set(this.config.features),
settings: new Map(this.config.settings)
});
}
}
interface ConditionalMethods {
then(callback: (builder: ConditionalBuilder) => void): ConditionalBuilder;
else(callback: (builder: ConditionalBuilder) => void): ConditionalBuilder;
}
// Usage with conditionals 🌟
const isPremiumUser = true;
const isDarkModeEnabled = false;
const appConfig = new ConditionalBuilder()
.enableFeature("basic-features")
.when(isPremiumUser, builder => {
builder
.enableFeature("premium-features")
.enableFeature("advanced-analytics")
.setSetting("storage-limit", "unlimited");
})
.if(isDarkModeEnabled)
.then(b => b.setSetting("theme", "dark"))
.else(b => b.setSetting("theme", "light"))
.setSetting("language", "en")
.build();
🎨 Type-Safe Builder Pattern
Create builders with compile-time validation:
// Step interfaces for type-safe building 🏗️
interface NeedsName {
withName(name: string): NeedsEmail;
}
interface NeedsEmail {
withEmail(email: string): NeedsPassword;
}
interface NeedsPassword {
withPassword(password: string): CanBuild;
}
interface CanBuild {
withRole(role: string): CanBuild;
withPhone(phone: string): CanBuild;
build(): User;
}
class User {
constructor(
public name: string,
public email: string,
public password: string,
public role: string = "user",
public phone?: string
) {}
}
class UserBuilder implements NeedsName, NeedsEmail, NeedsPassword, CanBuild {
private userData = {
name: "",
email: "",
password: "",
role: "user",
phone: undefined as string | undefined
};
// Static factory method to start the chain 🏁
static create(): NeedsName {
return new UserBuilder();
}
withName(name: string): NeedsEmail {
this.userData.name = name;
return this;
}
withEmail(email: string): NeedsPassword {
if (!email.includes("@")) {
throw new Error("Invalid email format");
}
this.userData.email = email;
return this;
}
withPassword(password: string): CanBuild {
if (password.length < 8) {
throw new Error("Password must be at least 8 characters");
}
this.userData.password = password;
return this;
}
withRole(role: string): CanBuild {
this.userData.role = role;
return this;
}
withPhone(phone: string): CanBuild {
this.userData.phone = phone;
return this;
}
build(): User {
return new User(
this.userData.name,
this.userData.email,
this.userData.password,
this.userData.role,
this.userData.phone
);
}
}
// Type-safe building - won't compile if steps are skipped! 🛡️
const user = UserBuilder.create()
.withName("John Doe")
.withEmail("[email protected]")
.withPassword("securepass123")
.withRole("admin")
.build();
// This won't compile! 🚫
// const badUser = UserBuilder.create()
// .withEmail("[email protected]") // Error: Property 'withEmail' does not exist
// .build();
🔗 Immutable Chaining
Create chains that return new instances instead of modifying the original:
class ImmutableList<T> {
private constructor(private readonly items: ReadonlyArray<T>) {}
static of<T>(...items: T[]): ImmutableList<T> {
return new ImmutableList(items);
}
// Each method returns a new instance 🆕
add(item: T): ImmutableList<T> {
return new ImmutableList([...this.items, item]);
}
remove(index: number): ImmutableList<T> {
const newItems = [...this.items];
newItems.splice(index, 1);
return new ImmutableList(newItems);
}
filter(predicate: (item: T) => boolean): ImmutableList<T> {
return new ImmutableList(this.items.filter(predicate));
}
map<U>(transformer: (item: T) => U): ImmutableList<U> {
return new ImmutableList(this.items.map(transformer));
}
sort(compareFn?: (a: T, b: T) => number): ImmutableList<T> {
return new ImmutableList([...this.items].sort(compareFn));
}
reverse(): ImmutableList<T> {
return new ImmutableList([...this.items].reverse());
}
// Terminal operations 🏁
toArray(): T[] {
return [...this.items];
}
get length(): number {
return this.items.length;
}
get(index: number): T | undefined {
return this.items[index];
}
}
// Original list is never modified! 🛡️
const original = ImmutableList.of(1, 2, 3);
const modified = original.add(4).add(5).filter(n => n % 2 === 0);
console.log(original.toArray()); // [1, 2, 3] - unchanged!
console.log(modified.toArray()); // [2, 4]
⚠️ Common Pitfalls and Solutions
🚫 Pitfall 1: Forgetting to Return ‘this’
class BadChaining {
private value = 0;
// ❌ Wrong: Forgot to return this
add(n: number) {
this.value += n;
// No return statement!
}
// ❌ This won't work
multiply(n: number): void {
this.value *= n;
}
}
// const bad = new BadChaining().add(5).multiply(2); // Error! 🚫
class GoodChaining {
private value = 0;
// ✅ Correct: Always return this
add(n: number): this {
this.value += n;
return this;
}
multiply(n: number): this {
this.value *= n;
return this;
}
getValue(): number {
return this.value;
}
}
const good = new GoodChaining().add(5).multiply(2).getValue(); // 10 ✅
🚫 Pitfall 2: Breaking the Chain with Wrong Return Types
// ❌ Bad: Inconsistent return types break the chain
class InconsistentChain {
private data: string[] = [];
add(item: string): this {
this.data.push(item);
return this;
}
// This breaks the chain!
count(): number {
return this.data.length;
}
// Can't chain after count()
remove(item: string): this {
const index = this.data.indexOf(item);
if (index > -1) this.data.splice(index, 1);
return this;
}
}
// ✅ Good: Separate terminal and chainable methods
class ConsistentChain {
private data: string[] = [];
// Chainable methods
add(item: string): this {
this.data.push(item);
return this;
}
remove(item: string): this {
const index = this.data.indexOf(item);
if (index > -1) this.data.splice(index, 1);
return this;
}
// Terminal methods (clearly marked)
getCount(): number {
return this.data.length;
}
toArray(): string[] {
return [...this.data];
}
}
🚫 Pitfall 3: Mutating Shared State
// ❌ Bad: Shared mutable state
class SharedStateBuilder {
private static sharedConfig = {}; // Shared across all instances! 😱
set(key: string, value: any): this {
SharedStateBuilder.sharedConfig[key] = value;
return this;
}
}
// ✅ Good: Instance-specific state
class InstanceStateBuilder {
private config = {}; // Each instance has its own state
set(key: string, value: any): this {
this.config[key] = value;
return this;
}
build(): Readonly<any> {
return Object.freeze({...this.config});
}
}
🛠️ Best Practices
1️⃣ Clear Method Names
class APIRequestBuilder {
private request = {
method: "GET",
headers: new Map<string, string>(),
params: new URLSearchParams(),
body: null as any
};
// ✅ Clear, intention-revealing names
get(url: string): this {
this.request.method = "GET";
return this;
}
post(url: string): this {
this.request.method = "POST";
return this;
}
withHeader(key: string, value: string): this {
this.request.headers.set(key, value);
return this;
}
withParam(key: string, value: string): this {
this.request.params.append(key, value);
return this;
}
withBody(data: any): this {
this.request.body = data;
return this;
}
withAuth(token: string): this {
return this.withHeader("Authorization", `Bearer ${token}`);
}
// Terminal method with clear name
send(): Promise<Response> {
// Implementation
console.log("📤 Sending request:", this.request);
return Promise.resolve(new Response());
}
}
2️⃣ Logical Method Grouping
class DocumentBuilder {
private doc = {
metadata: {} as any,
content: [] as any[],
styles: {} as any
};
// Metadata methods 📋
title(text: string): this {
this.doc.metadata.title = text;
return this;
}
author(name: string): this {
this.doc.metadata.author = name;
return this;
}
createdAt(date: Date): this {
this.doc.metadata.createdAt = date;
return this;
}
// Content methods 📝
addParagraph(text: string): this {
this.doc.content.push({ type: "paragraph", text });
return this;
}
addHeading(text: string, level: number = 1): this {
this.doc.content.push({ type: "heading", text, level });
return this;
}
addImage(url: string, alt: string): this {
this.doc.content.push({ type: "image", url, alt });
return this;
}
// Style methods 🎨
withFont(fontFamily: string): this {
this.doc.styles.fontFamily = fontFamily;
return this;
}
withTheme(theme: "light" | "dark"): this {
this.doc.styles.theme = theme;
return this;
}
// Build method 🏗️
build(): Document {
return new Document(this.doc);
}
}
class Document {
constructor(private data: any) {}
}
3️⃣ Error Handling in Chains
class SafeBuilder {
private errors: string[] = [];
private data = {} as any;
// Store errors instead of throwing immediately 🛡️
setValue(key: string, value: any): this {
if (!key) {
this.errors.push("Key cannot be empty");
return this;
}
if (value === null || value === undefined) {
this.errors.push(`Value for ${key} cannot be null or undefined`);
return this;
}
this.data[key] = value;
return this;
}
validate(validator: (data: any) => string | null): this {
const error = validator(this.data);
if (error) {
this.errors.push(error);
}
return this;
}
// Check for errors before building 🚦
build(): Result<any> {
if (this.errors.length > 0) {
return { success: false, errors: this.errors };
}
return { success: true, data: this.data };
}
}
interface Result<T> {
success: boolean;
data?: T;
errors?: string[];
}
// Usage with error handling
const result = new SafeBuilder()
.setValue("name", "John")
.setValue("", "Invalid") // Error!
.setValue("age", null) // Error!
.validate(data => data.name.length < 3 ? "Name too short" : null)
.build();
if (!result.success) {
console.log("❌ Errors:", result.errors);
}
🧪 Hands-On Exercise
Time to practice! 🎯 Create a fluent HTTP client builder:
// Your challenge: Complete this HTTP client builder! 💪
class HttpClient {
private config = {
baseURL: "",
headers: new Map<string, string>(),
timeout: 5000,
retries: 3,
interceptors: {
request: [] as Array<(config: any) => any>,
response: [] as Array<(response: any) => any>
}
};
// TODO: Implement baseURL(url: string): this
// Should set the base URL for all requests
// TODO: Implement header(key: string, value: string): this
// Should add a default header
// TODO: Implement timeout(ms: number): this
// Should set request timeout
// TODO: Implement retry(times: number): this
// Should set retry count
// TODO: Implement interceptRequest(fn: (config: any) => any): this
// Should add request interceptor
// TODO: Implement interceptResponse(fn: (response: any) => any): this
// Should add response interceptor
// TODO: Implement auth(token: string): this
// Should set Authorization header
// TODO: Implement get(path: string): RequestBuilder
// Should return a new RequestBuilder for GET requests
// TODO: Implement post(path: string): RequestBuilder
// Should return a new RequestBuilder for POST requests
}
class RequestBuilder {
// TODO: Implement the request builder
// Should support:
// - param(key: string, value: string): this
// - body(data: any): this
// - header(key: string, value: string): this
// - send(): Promise<any>
}
// Test your implementation! 🧪
const client = new HttpClient()
.baseURL("https://api.example.com")
.header("Content-Type", "application/json")
.timeout(10000)
.retry(5)
.auth("my-token");
const response = await client
.get("/users")
.param("page", "1")
.param("limit", "10")
.send();
💡 Solution (click to reveal)
class HttpClient {
private config = {
baseURL: "",
headers: new Map<string, string>(),
timeout: 5000,
retries: 3,
interceptors: {
request: [] as Array<(config: any) => any>,
response: [] as Array<(response: any) => any>
}
};
baseURL(url: string): this {
this.config.baseURL = url.replace(/\/$/, ""); // Remove trailing slash
return this;
}
header(key: string, value: string): this {
this.config.headers.set(key, value);
return this;
}
timeout(ms: number): this {
if (ms <= 0) throw new Error("Timeout must be positive");
this.config.timeout = ms;
return this;
}
retry(times: number): this {
if (times < 0) throw new Error("Retry count must be non-negative");
this.config.retries = times;
return this;
}
interceptRequest(fn: (config: any) => any): this {
this.config.interceptors.request.push(fn);
return this;
}
interceptResponse(fn: (response: any) => any): this {
this.config.interceptors.response.push(fn);
return this;
}
auth(token: string): this {
return this.header("Authorization", `Bearer ${token}`);
}
get(path: string): RequestBuilder {
return new RequestBuilder(this.config, "GET", path);
}
post(path: string): RequestBuilder {
return new RequestBuilder(this.config, "POST", path);
}
put(path: string): RequestBuilder {
return new RequestBuilder(this.config, "PUT", path);
}
delete(path: string): RequestBuilder {
return new RequestBuilder(this.config, "DELETE", path);
}
}
class RequestBuilder {
private requestConfig = {
params: new URLSearchParams(),
headers: new Map<string, string>(),
body: null as any
};
constructor(
private clientConfig: any,
private method: string,
private path: string
) {
// Copy client headers
clientConfig.headers.forEach((value: string, key: string) => {
this.requestConfig.headers.set(key, value);
});
}
param(key: string, value: string): this {
this.requestConfig.params.append(key, value);
return this;
}
body(data: any): this {
this.requestConfig.body = data;
return this;
}
header(key: string, value: string): this {
this.requestConfig.headers.set(key, value);
return this;
}
async send(): Promise<any> {
// Build final config
let config = {
method: this.method,
url: `${this.clientConfig.baseURL}${this.path}`,
headers: Object.fromEntries(this.requestConfig.headers),
timeout: this.clientConfig.timeout,
params: this.requestConfig.params.toString(),
body: this.requestConfig.body
};
// Apply request interceptors
for (const interceptor of this.clientConfig.interceptors.request) {
config = interceptor(config);
}
console.log(`📤 ${this.method} ${config.url}`);
if (config.params) console.log(` Params: ${config.params}`);
if (config.body) console.log(` Body:`, config.body);
// Simulate request with retries
let lastError;
for (let attempt = 0; attempt <= this.clientConfig.retries; attempt++) {
try {
// Simulated response
let response = {
status: 200,
data: { message: "Success", attempt }
};
// Apply response interceptors
for (const interceptor of this.clientConfig.interceptors.response) {
response = interceptor(response);
}
return response;
} catch (error) {
lastError = error;
if (attempt < this.clientConfig.retries) {
console.log(`🔄 Retry attempt ${attempt + 1}/${this.clientConfig.retries}`);
}
}
}
throw lastError;
}
}
// Advanced usage example
const api = new HttpClient()
.baseURL("https://api.example.com")
.header("Content-Type", "application/json")
.header("X-API-Version", "2.0")
.timeout(10000)
.retry(3)
.interceptRequest(config => {
console.log("🔧 Request interceptor:", config.method, config.url);
return { ...config, timestamp: Date.now() };
})
.interceptResponse(response => {
console.log("📥 Response interceptor:", response.status);
return { ...response, cached: false };
})
.auth("secret-token");
// Fluent request building
const users = await api
.get("/users")
.param("page", "1")
.param("limit", "10")
.param("sort", "created_at")
.header("X-Request-ID", "123")
.send();
const newUser = await api
.post("/users")
.body({ name: "John Doe", email: "[email protected]" })
.header("X-Idempotency-Key", "unique-key")
.send();
🎓 Key Takeaways
You’ve mastered method chaining and fluent interfaces! Here’s what you’ve learned:
-
Method Chaining Basics 🔗:
- Return
this
from methods - Create readable, flowing APIs
- Distinguish chainable vs terminal methods
- Build complex objects step by step
- Return
-
Fluent Interface Design 🌊:
- Use descriptive method names
- Group related methods logically
- Provide clear terminal methods
- Enable natural language-like code
-
Advanced Patterns 🚀:
- Type-safe builders
- Conditional chaining
- Immutable chains
- Error accumulation
-
Best Practices 🌟:
- Keep methods focused
- Avoid breaking the chain
- Handle errors gracefully
- Document the expected flow
🤝 Next Steps
Congratulations on mastering fluent interfaces! 🎉 You can now create APIs that are a joy to use. Here’s what to explore next:
- 🎭 Abstract Classes: Design blueprint classes
- 🧬 Inheritance: Extend classes effectively
- 🎨 Decorators: Add metadata to your classes
- 🏗️ Design Patterns: Builder, Factory, and more
Remember: Great APIs aren’t just functional—they’re beautiful to use! Make your code sing! 🎵
Happy coding! 🚀✨