Moving Beyond Spaghetti Code
Writing code that works is the easy part. The real challenge starts when your project hits 10,000 lines and every small change feels like you are playing Jenga. Early in my TypeScript journey, my files usually devolved into 500-line monsters filled with global variables and nested if-else blocks. Switching to design patterns wasn’t just a stylistic choice; it was the only way to keep my sanity as the codebase grew.
Design patterns are battle-tested blueprints for solving recurring architectural problems. In TypeScript, these patterns become even more powerful by using interfaces and strict typing to catch errors before you even hit save. We are going to look at three essential patterns: Singleton, Factory, and Strategy.
Comparing Approaches: Procedural vs. Pattern-Oriented
It is easier to appreciate patterns when you see the mess they prevent. Let’s look at how we typically handle logic before leveling up our architecture.
The Procedural Mess
In a procedural setup, logic flows in a straight line. If you need a database connection, you might instantiate it globally or pass it through a chain of twelve different functions. When you need to support three different payment types, you end up with a massive switch statement inside your core business logic. This works for a weekend side project, but it is a nightmare to unit test or expand once you have multiple contributors.
The Pattern-Oriented Solution
Using patterns shifts your mindset toward object-oriented design. Instead of focusing on the raw sequence of steps, you think about which component owns the logic and how they talk to each other. Logic is tucked away where it belongs. If a client asks for a new ‘Crypto’ payment method, you simply drop in a new class. You don’t have to touch your existing, tested code. This separation makes your system modular and resilient.
The Trade-offs: Is It Worth the Extra Code?
Patterns aren’t a magic fix for every problem. I have seen developers over-engineer a simple landing page into a complex web of factories. Here is the reality of using them in production.
The Wins
- Easier Maintenance: You can fix a bug in your logging logic without worrying about breaking your payment processing.
- Instant Context: When a new developer sees a ‘Factory’ folder, they immediately know the intent of that code without reading every line.
- Reliable Testing: Because logic is encapsulated, you can write focused unit tests. Mocking a single interface is much easier than mocking a global state.
The Costs
- Verbosity: You might end up writing 15-20% more boilerplate code upfront.
- Learning Curve: Junior developers on your team might need a quick briefing to understand why you aren’t just using a simple
new MyClass()call.
Setting Up Your Environment
If you want to test these patterns, you only need a basic TypeScript setup. I usually organize my source folder by pattern name to keep things tidy.
# Quick setup
mkdir ts-patterns && cd ts-patterns
npm init -y
npm install typescript ts-node @types/node --save-dev
npx tsc --init
Try organizing your files like this:
src/
├── singleton/
├── factory/
└── strategy/
└── index.ts
1. The Singleton Pattern: Controlling the Instance
The Singleton ensures a class has exactly one instance throughout your application’s lifecycle. This is the go-to choice for managing database pools or global configuration settings where multiple instances would waste memory or cause sync issues.
The Implementation
class DatabaseConnection {
private static instance: DatabaseConnection;
private constructor() {
// The private constructor prevents 'new DatabaseConnection()' calls
console.log("Initializing unique database pool...");
}
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 - both variables point to the same memory address
By locking the constructor, I stop other developers from accidentally opening five different database connections. This keeps resource usage predictable and prevents connection leaks.
2. The Factory Pattern: Object Creation Made Flexible
The Factory pattern provides a way to create objects without specifying the exact class of the object that will be created. I use this most often when dealing with integrations, like different notification services or logging levels.
The Implementation
interface Logger {
log(message: string): void;
}
class FileLogger implements Logger {
log(message: string) { console.log(`[File] ${message}`); }
}
class ConsoleLogger implements Logger {
log(message: string) { console.log(`[Console] ${message}`); }
}
class LoggerFactory {
public static createLogger(type: 'file' | 'console'): Logger {
if (type === 'file') return new FileLogger();
return new ConsoleLogger();
}
}
// Usage
const logger = LoggerFactory.createLogger('file');
logger.log("User signed in");
This setup separates the ‘how’ of creation from the ‘what’ of the logic. If you need to switch from a local file logger to an AWS S3 logger, you only change one line in the factory. The rest of your app remains untouched.
3. The Strategy Pattern: Swapping Logic on the Fly
The Strategy pattern defines a family of algorithms and makes them interchangeable. This is my favorite pattern for handling checkout systems. It allows the application to switch between PayPal, Stripe, or Bitcoin payments at runtime without messy conditionals.
The Implementation
interface PaymentStrategy {
pay(amount: number): void;
}
class PaypalStrategy implements PaymentStrategy {
pay(amount: number) { console.log(`Processing $${amount} via PayPal.`); }
}
class CreditCardStrategy implements PaymentStrategy {
pay(amount: number) { console.log(`Charging $${amount} to Credit Card.`); }
}
class ShoppingCart {
private strategy: PaymentStrategy;
constructor(strategy: PaymentStrategy) {
this.strategy = strategy;
}
public setStrategy(strategy: PaymentStrategy) {
this.strategy = strategy;
}
public checkout(amount: number) {
this.strategy.pay(amount);
}
}
// Usage
const cart = new ShoppingCart(new PaypalStrategy());
cart.checkout(49.99);
// User changes mind and wants to use a card
cart.setStrategy(new CreditCardStrategy());
cart.checkout(49.99);
The ShoppingCart doesn’t need to know the internal details of PayPal’s API. It simply trusts the pay contract. This makes adding a tenth payment method just as easy as adding the second one.
Final Thoughts and Best Practices
Patterns are tools, not rules. As you integrate these into your TypeScript workflow, keep these three points in mind:
- Avoid over-engineering: If a simple 10-line function solves the problem, don’t build a Factory for it. Use patterns only when you anticipate complexity or need high testability.
- Lean on Interfaces: TypeScript’s biggest strength is its type system. Always define clear interfaces for your strategies and factories to ensure your code is self-documenting.
- Explain the ‘Why’: If you implement a complex pattern, leave a brief comment. Explain why you chose a Strategy over a simple switch statement so your teammates can follow your logic.
Mastering these three patterns will change how you view software. You stop writing scripts and start engineering systems that are actually a joy to maintain.

