DEV Community

Rajendra
Rajendra

Posted on • Originally published at rajendrakhope.com on

Design Patterns in TypeScript: A Developer's Journey from Chaos to Clarity

Design Patterns in TypeScript: A Developer's Journey from Chaos to Clarity

Let's be honest - most JavaScript developers (myself included) have been gloriously lazy about design patterns. If youโ€™ve been in the JavaScript ecosystem for a while, you remember the "Jungle Raj" days ๐Ÿ™‚. Most of us front-end folks treated design patterns like that gym membership we swear we'll use next month.

JavaScript was loose, dynamic, and forgiving - perfect for quick prototypes, but a nightmare when your app grew legs and started running a marathon.

Design patterns? Sounds like something Java developers invented to feel important. JavaScript worked just fine with a few functions, some closures, and vibes.

Back then, the concept of Design Patterns felt like something reserved for Java developers wearing suits and ties. JavaScript developers? We were the cowboys ๐Ÿค . we had var, jQuery, and a dream. If it worked, we shipped it. If we needed to reuse code, we copy-pasted it.

We'd throw everything into global scope, mutate state like it owed us money ๐Ÿค‘, and pray that "it just works" in production. Singleton? Nah, just use a global variable. Factory? Who needs that when you can create new objects everywhere? And don't get me started on testing good luck mocking that mess.

  • โ€œDynamic Chaosโ€: JS was so loosely typed that implementing strict OOP patterns felt like trying to build a skyscraper ๐Ÿข out of rubber bands and Fevicol.
  • โ€œPrototype Confusionโ€: Prototypal inheritance was weird.
  • "It works on my machine!" (Translation: Future-me is going to hate present-me).
  • "We'll refactor it later..." (Narrator: They never did)
  • "It's too much boilerplate!" (Translation: I don't want to write more code)

Then TypeScript crashed the party ๐ŸŽ‰. Suddenly, we had types, interfaces, classes that actually meant something, and a compiler yelling at us when we tried to pull our old tricks. TypeScript didn't just add safety; it nudged us toward better architecture. It made implementing classic design patterns feel natural, not forced. No more excuses - your code could be clean, maintainable, and scalable without sacrificing that JavaScript joy.

Today, especially in Angular apps (where TypeScript shines), ignoring patterns is like driving a Ferrari ๐ŸŽ๏ธ in first gear. Let's dive into some essential ones with TypeScript examples.

I'll keep it real: simple explanations, why they matter, practical uses, and how Angular already sneaks them in ๐Ÿ˜ƒ

Design Patterns in TypeScript: A Developer's Journey from Chaos to Clarity
Reusable Solutions | Scalable Architectures

Creational Patterns

These bad boys ๐Ÿ˜Ž handle object creation - making sure you're not spawning instances like zombies ๐ŸงŸ in a bad horror movie.

Singleton - The "There Can Be Only One" Pattern.

  1. In a Nutshell: Ensures a class has only one instance and provides a global point of access to it. In TypeScript, we can enforce this with a private constructor and a static getInstance method.
  2. Use Case: Singleton when you need exactly one instance of a class throughout your application - like a configuration manager, logger, or app state holder. Prevents duplicates, saves memory, and avoids inconsistencies.
  3. Show Me :
class ConfigService {
  private static instance: ConfigService;
  private config = { apiUrl: 'https://api.example.com' };

  private constructor() {} // Private constructor prevents direct instantiation

  static getInstance(): ConfigService {
    if (!ConfigService.instance) {
      ConfigService.instance = new ConfigService();
    }
    return ConfigService.instance;
  }

  getApiUrl() {
    return this.config.apiUrl;
  }
}

// Usage
const config1 = ConfigService.getInstance();
const config2 = ConfigService.getInstance(); // // true - same instance!
Enter fullscreen mode Exit fullscreen mode
  1. Angular tie-in: Angular's services with providedIn: 'root' are essentially Singletons! When you create a service with this configuration, Angular's dependency injection ensures only one instance exists application wide.
@Injectable({ providedIn: 'root' })
export class AuthService {
  // This is a Singleton across your entire app!
}
Enter fullscreen mode Exit fullscreen mode

Factory Method - The "Don't Make Me Think" ๐Ÿค” Pattern.

  1. In a Nutshell : The Factory Method defines an interface for creating objects but let's subclasses decide which class to instantiate. It's like a vending machine - you press a button (call a method) and out comes the product you need. Great for deferring creation logic.
  2. Use Case : Use this when you don't know ahead of time what exact type of object you need to create, or when object creation logic is complex. Perfect for creating different types of objects based on runtime conditions. Decouples your code. If you change how an object is created, you only change the Factory, not the 50 places you used it.
  3. Show Me (creating different loggers):
//Logger Interface
interface Logger {
  log(message: string): void;
}
// Create Logger
class ConsoleLogger implements Logger {
  log(message: string) { console.log(message); }
}

class FileLogger implements Logger {
  log(message: string) { /* Write to file */ }
}
//Factory
class LoggerFactory {
  createLogger(type: 'console' | 'file'): Logger {
    if (type === 'console') return new ConsoleLogger();
    if (type === 'file') return new FileLogger();
    throw new Error('Unknown logger');
  }
}

// Usage
const factory = new LoggerFactory();
const logger = factory.createLogger('console');
logger.log('Hello Factory!');
Enter fullscreen mode Exit fullscreen mode
  1. Angular tie-in : Often used with useFactory in providers for dynamic dependency creation, like environment-specific services. Dynamic Component Loading involves factory-like patterns where the framework decides which component factory to use to render a component on the fly.

Builder - The "Subway Sandwich" ๐Ÿฅช Pattern.

  1. In a Nutshell : Separates the construction of a complex object from its representation, allowing step-by-step building. Instead of a constructor with 47 parameters (we've all been there - new User(true, false, 'red', 10, ...)), you build the object step by step.
  2. Use Case : Use Builder when you have a complex object with many optional parameters or when construction requires multiple steps. It makes your code readable and prevents "telescoping constructor" nightmares.
  3. Show Me (building a complex query):
class QueryBuilder {
  private query = '';
  where(condition: string) { this.query += `WHERE ${condition} `; return this; }
  orderBy(field: string) { this.query += `ORDER BY ${field} `; return this; }
  limit(count: number) { this.query += `LIMIT ${count} `; return this; }
  build() { return this.query.trim(); }
}

// Usage (fluent APIโ€”feels like magic)
const sql = new QueryBuilder()
  .where('age > 18')
  .orderBy('name')
  .limit(10)
  .build();
// "WHERE age > 18 ORDER BY name LIMIT 10"

Enter fullscreen mode Exit fullscreen mode
  1. Angular tie-in : Angular's Reactive Forms FormBuilder is a textbook example of the Builder pattern! It lets you construct complex forms step by step:
this.myForm = this.formBuilder.group({
  name: ['', Validators.required],
  email: ['', [Validators.required, Validators.email]],
  address: this.formBuilder.group({
    street: [''],
    city: ['']
  })
});

Enter fullscreen mode Exit fullscreen mode

Structural Patterns - Composition & Relationships .

These focus on how objects are composed, making big things from small ones without tight coupling. Works on how to make different objects play nice together.

Adapter - The "Travel Plug" ๐Ÿ”Œ Pattern

  1. In a Nutshell : Converts the interface of a class into another that clients expect. The Adapter pattern allows incompatible interfaces to work together. It's like a power adapter when you travel abroad - it makes your Indian plug work in a US socket.
  2. Use Case : Use this when you need to integrate third-party libraries, legacy code, or any system with an incompatible interface. It's a lifesaver when you can't modify the source code but need to make it work with your system.
  3. Show Me (adapting old API data):
// Old payment system (can't modify)
class LegacyPaymentProcessor {
  processPayment(amount: number): void {
    console.log(`Processing $${amount} via legacy system...`);
  }
}

// New interface our app expects
interface ModernPaymentGateway {
  pay(amount: number, currency: string): void;
  refund(transactionId: string): void;
}

// Adapter bridges the gap
class PaymentAdapter implements ModernPaymentGateway {
  private legacyProcessor: LegacyPaymentProcessor;

  constructor() {
    this.legacyProcessor = new LegacyPaymentProcessor();
  }

  pay(amount: number, currency: string): void {
    console.log(`Converting ${currency} to USD...`);
    // Convert and delegate to legacy system
    this.legacyProcessor.processPayment(amount);
  }

  refund(transactionId: string): void {
    console.log(`Refunding transaction ${transactionId}`);
    // Legacy system doesn't support refunds directly
    // We implement workaround here
  }
}

// Usage
const payment: ModernPaymentGateway = new PaymentAdapter();
payment.pay(99.99, 'EUR');

Enter fullscreen mode Exit fullscreen mode
  1. Angular tie-in : Angular's HttpClient is an adapter! It wraps the browser's native XMLHttpRequest and Fetch APIs, providing a consistent, Observable-based interface regardless of the underlying implementation.

Decorator - The "Sprinkles" ๐Ÿง Pattern.

  1. In a Nutshell : TypeScript loves decorators! It helps in keeping classes clean. It adds behavior to objects dynamically without modifying their code. The Decorator pattern attaches additional responsibilities to objects dynamically. Think of it like adding toppings to a pizza ๐Ÿ•- each topping adds functionality without changing the base pizza class.
  2. Use Case : For extending functionality orthogonally. Use Decorators when you need to add functionality to objects without modifying their structure or when inheritance would create too many subclasses. They're perfect for adding cross-cutting concerns like logging, caching, or validation.
  3. Show Me :
function Log(target: any, key: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;
  descriptor.value = function(...args: any[]) {
    console.log(`Calling ${key} with`, args);
    return original.apply(this, args);
  };
}
class Calculator {
  @Log
  add(a: number, b: number) { return a + b; }
}
Enter fullscreen mode Exit fullscreen mode
  1. Angular tie-in : Angular's @Component, @Injectable,@Input, @Output are all decorators! They add metadata and functionality to classes without modifying them. TypeScript's decorator syntax made this pattern a first-class citizen in Angular.

Facade - The "Universal Remote" Pattern.

  1. In a Nutshell : The Facade pattern provides a simplified interface to a complex subsystem. It's like a TV remote - you press "power" instead of manually managing electron guns, phosphor screens, and signal processors.
  2. Use Case : Hides complexity - your components stay dumb and happy. Use this when your component is doing too much heavy lifting (calling 3 different services, parsing data, handling errors). Move that logic into a Facade
  3. Show Me (wrapping multiple services):
// Complex subsystem classes
class AuthService {
  login() {}
}

class TokenService {
  store() {}
}
// Facade - simplifies everything!
class UserFacade {
  constructor(
    private auth: AuthService,
    private token: TokenService
  ) {}
  loginUser() {
    this.auth.login();
    this.token.store();
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. Angular tie-in : Angular's Router is a perfect Facade! It hides the complexity of URL parsing, navigation guards, route matching, and component activation behind a simple API like router.navigate(['/users']).

Behavioral Patterns: Communication

These deal with object collaboration - Who talks to whom, and how.

Observer - The "YouTube Subscribe Button" Pattern.

  1. In a Nutshell : The Observer pattern defines a one-to-many dependency where when one object changes state, all its dependents are notified automatically. Think of it like a YouTube channel - subscribers get notified whenever new content drops..
  2. Use Case : For event handling, reactive updates - Use Observer when you need to maintain consistency between related objects or when an object needs to notify other objects without knowing who they are. It's perfect for event handling and maintaining loose coupling. It stops us from falling into "Callback Hell."
  3. Show Me:
// Subject (Observable)
interface Subject {
  attach(observer: Observer): void;
  detach(observer: Observer): void;
  notify(): void;
}

// Observer
interface Observer {
  update(subject: Subject): void;
}

// Concrete Subject
class StockTicker implements Subject {
  private observers: Observer[] = [];
  private price: number = 100;

  attach(observer: Observer): void {
    console.log("Observer attached");
    this.observers.push(observer);
  }

  detach(observer: Observer): void {
    const index = this.observers.indexOf(observer);
    if (index > -1) {
      this.observers.splice(index, 1);
      console.log("Observer detached");
    }
  }

  notify(): void {
    console.log("Notifying observers...");
    this.observers.forEach(observer => observer.update(this));
  }

  setPrice(price: number): void {
    this.price = price;
    this.notify();
  }

  getPrice(): number {
    return this.price;
  }
}

// Concrete Observers
class InvestorObserver implements Observer {
  constructor(private name: string) {}

  update(subject: Subject): void {
    if (subject instanceof StockTicker) {
      console.log(`${this.name} notified: Stock price is now $${subject.getPrice()}`);
    }
  }
}

class NewsAgencyObserver implements Observer {
  update(subject: Subject): void {
    if (subject instanceof StockTicker) {
      console.log(`๐Ÿ“ฐ Breaking News: Stock price changed to $${subject.getPrice()}`);
    }
  }
}

// Usage
const stockTicker = new StockTicker();
const investor1 = new InvestorObserver("Warren");
const investor2 = new InvestorObserver("Cathie");
const newsAgency = new NewsAgencyObserver();

stockTicker.attach(investor1);
stockTicker.attach(investor2);
stockTicker.attach(newsAgency);

stockTicker.setPrice(150); // Everyone gets notified!
stockTicker.setPrice(175); // Everyone gets notified again!

Enter fullscreen mode Exit fullscreen mode
  1. Angular tie-in : RxJS powers everything HttpClient returns observables, EventEmitter uses it under the hood, change detection reacts to async streams. RxJS is the ultimate implementation of this.
import { Subject } from 'rxjs';
const newsChannel = new Subject<string>();
// Subscriber 1
newsChannel.subscribe(news => console.log(`Viewer 1 read: ${news}`));
// Subscriber 2
newsChannel.subscribe(news => console.log(`Viewer 2 read: ${news}`));
newsChannel.next("TypeScript is awesome!");
Enter fullscreen mode Exit fullscreen mode

Strategy - The "Swiss Army Knife" Pattern.

  1. In a Nutshell : The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. It's like having different route options in Google Maps - driving, walking, biking - all doing the same thing (getting you there) but in different ways.
  2. Use Case : Use Strategy when you have multiple ways to do something and want to choose the algorithm at runtime. It eliminates conditionals and makes adding new strategies easy without modifying existing code. Swap behaviors at runtime - like different sorting or payment methods.

Show Me (different validation strategies):

// Strategy interface
interface PaymentStrategy {
  pay(amount: number): void;
}

// Concrete strategies
class CreditCardPayment implements PaymentStrategy {
  constructor(
    private cardNumber: string,
    private cvv: string
  ) {}

  pay(amount: number): void {
    console.log(`๐Ÿ’ณ Paid $${amount} using Credit Card ending in ${this.cardNumber.slice(-4)}`);
  }
}

class PayPalPayment implements PaymentStrategy {
  constructor(private email: string) {}

  pay(amount: number): void {
    console.log(`๐Ÿ…ฟ๏ธ Paid $${amount} using PayPal account ${this.email}`);
  }
}

class CryptoPayment implements PaymentStrategy {
  constructor(private walletAddress: string) {}

  pay(amount: number): void {
    console.log(`โ‚ฟ Paid $${amount} using Crypto wallet ${this.walletAddress.slice(0, 10)}...`);
  }
}

// Context
class ShoppingCart {
  private items: string[] = [];
  private paymentStrategy?: PaymentStrategy;

  addItem(item: string): void {
    this.items.push(item);
  }

  setPaymentStrategy(strategy: PaymentStrategy): void {
    this.paymentStrategy = strategy;
  }

  checkout(amount: number): void {
    if (!this.paymentStrategy) {
      throw new Error("Please select a payment method!");
    }
    this.paymentStrategy.pay(amount);
    this.items = []; // Clear cart
  }
}

// Usage - swap strategies at runtime!
const cart = new ShoppingCart();
cart.addItem("Laptop");
cart.addItem("Mouse");

// Customer chooses credit card
cart.setPaymentStrategy(new CreditCardPayment("1234-5678-9012-3456", "123"));
cart.checkout(1299.99);

// Next purchase with PayPal
cart.addItem("Keyboard");
cart.setPaymentStrategy(new PayPalPayment("user@example.com"));
cart.checkout(79.99);

Enter fullscreen mode Exit fullscreen mode
  1. Angular tie-in : Route Guards ( CanActivate ) or Custom Validators in forms are essentially strategies plugged into the framework. Angular's form validation uses the Strategy pattern! Each validator ( Validators.required, Validators.email, etc.) is a different validation strategy that can be applied to form controls.
this.formControl = new FormControl('', [
  Validators.required, // Strategy 1
  Validators.email, // Strategy 2
  Validators.minLength(8) // Strategy 3
]);
Enter fullscreen mode Exit fullscreen mode

Chain of Responsibility - The "Pass the Bucket" Pattern.

  1. In a Nutshell : Passes a request along a chain of handlers until one processes it. Chain of Responsibility passes a request along a chain of handlers. Each handler decides whether to process the request or pass it to the next handler. Think of it like customer support escalation - start with chat, then phone, then supervisor.
  2. Use Case : For sequential processing - like middleware or validation pipelines. Use this when you have multiple objects that can handle a request but you don't know which one in advance, or when the set of handlers should be dynamically determined. Perfect for validation pipelines, middleware, or event handling.
  3. Show Me :
// Handler interface
interface Handler {
  setNext(handler: Handler): Handler;
  handle(request: string): string | null;
}

// Base handler
abstract class AbstractHandler implements Handler {
  private nextHandler?: Handler;

  setNext(handler: Handler): Handler {
    this.nextHandler = handler;
    return handler; // Allows chaining: h1.setNext(h2).setNext(h3)
  }

  handle(request: string): string | null {
    if (this.nextHandler) {
      return this.nextHandler.handle(request);
    }
    return null;
  }
}

// Concrete handlers
class AuthenticationHandler extends AbstractHandler {
  handle(request: string): string | null {
    if (request.includes("authenticated")) {
      console.log("โœ… Authentication passed");
      return super.handle(request);
    }
    console.log("โŒ Authentication failed - stopping chain");
    return "Authentication required";
  }
}

class AuthorizationHandler extends AbstractHandler {
  handle(request: string): string | null {
    if (request.includes("authorized")) {
      console.log("โœ… Authorization passed");
      return super.handle(request);
    }
    console.log("โŒ Authorization failed - stopping chain");
    return "Insufficient permissions";
  }
}

class ValidationHandler extends AbstractHandler {
  handle(request: string): string | null {
    if (request.includes("valid")) {
      console.log("โœ… Validation passed");
      return super.handle(request);
    }
    console.log("โŒ Validation failed - stopping chain");
    return "Invalid data";
  }
}

class ProcessingHandler extends AbstractHandler {
  handle(request: string): string | null {
    console.log("โœ… Processing request...");
    return "Request processed successfully!";
  }
}

// Usage - build the chain
const auth = new AuthenticationHandler();
const authz = new AuthorizationHandler();
const validation = new ValidationHandler();
const processing = new ProcessingHandler();

// Chain them together
auth.setNext(authz).setNext(validation).setNext(processing);

// Test different scenarios
console.log("\n--- Test 1: Full valid request ---");
console.log(auth.handle("authenticated authorized valid data"));

console.log("\n--- Test 2: Missing authorization ---");
console.log(auth.handle("authenticated valid data"));

console.log("\n--- Test 3: Not authenticated ---");
console.log(auth.handle("valid data"));
Enter fullscreen mode Exit fullscreen mode
  1. Angular tie-in : HTTP Interceptors! Requests pass through a chain - auth adds tokens, logging logs, errors handle failures.
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    // Handle or pass to next interceptor in the chain
    const authReq = req.clone({
      headers: req.headers.set('Authorization', 'Bearer token')
    });
    return next.handle(authReq);
  }
}

Enter fullscreen mode Exit fullscreen mode

Wrapping It Up: Patterns Aren't Rules -They're Superpowers ๐Ÿช„

Look, nobody's saying you must slap a pattern on every line of code (that way lies over-engineering madness). But with TypeScript's structure and Angular's opinionated setup, these patterns stop being academic and start making your life easier. Cleaner code, fewer bugs, happier teammates - and yeah, that smug feeling when your app scales without imploding.

The TypeScript Advantage: Why It Makes a Difference

Before TypeScript, implementing these patterns in JavaScript felt like building a house without blueprints. TypeScript didn't just make these patterns possible in JavaScript; it made them desirable. What once felt like an unnecessary ceremony now feels like craftsmanship. Sure, it could be done, but you'd probably have a bathroom in the kitchen and stairs leading to nowhere.

TypeScript brings:

  1. Type Safety - Interfaces ensure your Observers actually implement update(), your Strategies have execute(), and your Factories return the right types.
  2. Better IDE Support - Autocomplete, refactoring, and inline documentation make using patterns natural instead of painful.
  3. Compile-Time Checks - Catch errors before runtime. Forgot to implement a method? TypeScript will let you know before your users do.
  4. Self-Documenting Code - Interfaces and types serve as documentation that never gets outdated (because if it does, your code won't compile).
  5. Easier Refactoring - Change an interface and TypeScript tells you everywhere that needs updating. No more grep-and-pray.

Next time you're knee-deep in a feature, ask: "Could a pattern save me here?" Chances are, yes. Go forth, build awesome things, and remember great code isn't about showing off - it's about future-you thanking present-you with a cold beverage.

Pro Tips for Using Design Patterns

For New Developers:

  • Don't try to memorize all patterns at once. Learn them as you encounter problems they solve.
  • Start with patterns you see in frameworks you use (like Angular). Understanding them there helps you apply them elsewhere.
  • Overusing patterns is worse than not using them. If a simple solution works, use it.
  • Read other people's code. See patterns in the wild, in real projects.

For Experienced Developers:

  • Patterns are tools, not rules. Know when to break them.
  • Combine patterns thoughtfully. Observer + Strategy? Beautiful. Ten patterns in one class? Nightmare.
  • Consider your team's skill level. Sometimes simpler code is better code.
  • Refactor toward patterns gradually. Don't rewrite everything on day one.
  • Use TypeScript's type system to enforce pattern contracts. Make it impossible to use your API wrong.

What pattern saved your "Roti" ๐Ÿž lately? Drop it in the comments - I'd love to hear your war stories! ๐Ÿš€

Sign up on my blog for latest stories

https://rajendrakhope.com/
No spam. Unsubscribe anytime.

Top comments (0)