When I first started transitioning from vanilla JavaScript to TypeScript, I made a common mistake: I treated TypeScript as just ‘JavaScript with types.’ While that gets you through the first few sprints, you quickly realize that as your codebase grows, types alone won’t save you from architectural decay. To build truly scalable systems, you need a solid grasp of typescript design patterns examples that leverage the language’s unique type system.
In this deep dive, I’ll move beyond the theoretical definitions you find in textbooks. I’ve spent the last few years implementing these patterns in production environments, and I’ve learned that the ‘classic’ Gang of Four patterns often need a slight tweak to feel natural in a modern TypeScript ecosystem. If you’re looking for a comprehensive look at advanced architectural concepts, you might also want to check out my typescript advanced patterns deep dive.
The Challenge: The “Type-Safe Spaghetti” Problem
The struggle isn’t usually lack of types; it’s the misuse of them. I often see developers creating massive ‘God Classes’ or deeply nested conditional logic that makes the code impossible to test. The challenge is creating a decoupling layer that allows your business logic to evolve without requiring a total rewrite of your data layer. This is where design patterns come in—they aren’t rigid rules, but proven templates for solving recurring problems.
Solution Overview: Choosing the Right Pattern
Design patterns are generally categorized into three buckets: Creational (how objects are born), Structural (how they fit together), and Behavioral (how they communicate). In TypeScript, we have a secret weapon: Interfaces and Abstract Classes. By coding to an interface rather than an implementation, we unlock the ability to swap logic at runtime without breaking our type safety.
Creational Patterns: Managing Object Creation
1. The Singleton Pattern
The Singleton ensures a class has only one instance and provides a global point of access to it. I frequently use this for configuration managers or database connection pools where creating multiple instances would be a resource disaster.
class DatabaseConnection {
private static instance: DatabaseConnection;
private constructor() {
console.log("Connecting to DB...");
}
public static getInstance(): DatabaseConnection {
if (!DatabaseConnection.instance) {
DatabaseConnection.instance = new DatabaseConnection();
}
return DatabaseConnection.instance;
}
public query(sql: string) {
console.log(`Executing: ${sql}`);
}
}
// Usage
const db1 = DatabaseConnection.getInstance();
const db2 = DatabaseConnection.getInstance();
console.log(db1 === db2); // true
2. The Factory Method Pattern
The Factory pattern is indispensable when your application needs to handle multiple types of a similar object. Instead of using a giant switch statement every time you need an instance, you encapsulate the logic in a factory.
interface Logger {
log(message: string): void;
}
class FileLogger implements Logger {
log(message: string) { console.log(`Writing to file: ${message}`); }
}
class CloudLogger implements Logger {
log(message: string) { console.log(`Sending to cloud: ${message}`); }
}
class LoggerFactory {
static createLogger(type: 'file' | 'cloud'): Logger {
if (type === 'file') return new FileLogger();
return new CloudLogger();
}
}
// Usage
const logger = LoggerFactory.createLogger('cloud');
logger.log("System started");
Behavioral Patterns: Orchestrating Communication
3. The Observer Pattern
This is the backbone of event-driven programming. In my experience, this is the most powerful pattern for decoupling a UI from its underlying data state. For a more academic look at how this fits into larger frameworks, I highly recommend the Effective TypeScript 2nd edition review where I discuss type-safe event handling.
interface Observer {
update(data: any): void;
}
class Subject {
private observers: Observer[] = [];
subscribe(observer: Observer) {
this.observers.push(observer);
}
notify(data: any) {
this.observers.forEach(obs => obs.update(data));
}
}
class UserInterface implements Observer {
update(data: any) { console.log(`UI updated with: ${data}`); }
}
// Usage
const newsFeed = new Subject();
const dashboard = new UserInterface();
newsFeed.subscribe(dashboard);
newsFeed.notify({ title: "New TypeScript Feature!" });
4. The Strategy Pattern
The Strategy pattern allows you to define a family of algorithms and make them interchangeable. This is perfect for payment gateways or shipping calculators where the logic changes based on the user’s selection.
interface PaymentStrategy {
pay(amount: number): void;
}
class PayPalPayment implements PaymentStrategy {
pay(amount: number) { console.log(`Paid ${amount} using PayPal`); }
}
class CreditCardPayment implements PaymentStrategy {
pay(amount: number) { console.log(`Paid ${amount} using Credit Card`); }
}
class CheckoutContext {
private strategy: PaymentStrategy;
constructor(strategy: PaymentStrategy) {
this.strategy = strategy;
}
setStrategy(strategy: PaymentStrategy) {
this.strategy = strategy;
}
executePayment(amount: number) {
this.strategy.pay(amount);
}
}
// Usage
const checkout = new CheckoutContext(new PayPalPayment());
checkout.executePayment(100);
checkout.setStrategy(new CreditCardPayment());
checkout.executePayment(200);
Implementation: Putting it Together in a Real Project
If you’re building a medium-to-large application, you won’t use just one pattern. You’ll likely use a Factory to create your Strategies, which are then managed by a Singleton service. As shown in the architectural logic described earlier, the key is to keep your concrete implementations hidden and your interfaces public.
When I implement these in my own projects, I follow a strict rule: Don’t over-engineer. If a simple function suffices, don’t force a Strategy pattern into it. Patterns are tools to manage complexity, not goals in themselves.
Pitfalls to Avoid
- The Singleton Trap: Overusing Singletons can make unit testing a nightmare because they introduce global state. Always consider Dependency Injection instead.
- Interface Bloat: Creating interfaces for every single class just because “a pattern says so” adds unnecessary boilerplate.
- Ignoring Composition: Remember that composition is often more flexible than inheritance. Favor composing small, focused objects over deep class hierarchies.
If you’re feeling overwhelmed by the sheer volume of patterns, start by implementing just one—the Factory pattern is usually the easiest win for most developers.