Welcome back, aspiring TypeScript architect! You’ve come a long way, mastering the fundamental building blocks of TypeScript. Now, it’s time to elevate your skills from writing functional code to crafting robust, maintainable, and scalable applications. This chapter is your gateway to understanding Design Patterns, a collection of proven solutions to common software design problems.

In this chapter, we’ll explore how TypeScript not only supports but enhances traditional design patterns, bringing an unparalleled level of type safety and clarity to your architectural choices. We’ll dive into practical implementations of key patterns, showing you how to leverage TypeScript’s powerful type system to build more reliable and understandable code. Get ready to think like an architect and build with confidence!

Before we jump in, make sure you’re comfortable with core TypeScript concepts we’ve covered, especially interfaces, classes, static members, and basic generics. If any of those feel a bit fuzzy, a quick refresh on Chapters 8-12 might be helpful.

What are Design Patterns and Why Do They Matter?

Imagine you’re building a house. You wouldn’t just start nailing boards together randomly, would you? You’d use blueprints, established construction techniques, and common solutions for things like doors, windows, and foundations. Design patterns are the “blueprints” and “established techniques” for software development.

They are generalized, reusable solutions to common problems that arise during software design. They aren’t finished code snippets you can just copy-paste; rather, they are templates for how to solve a problem that can be adapted to various situations.

Why are they so important, especially with TypeScript?

  1. Shared Vocabulary: When you say “Singleton,” other developers immediately understand its intent and structure.
  2. Proven Solutions: They represent best practices developed by experienced software engineers over decades.
  3. Maintainability and Scalability: Patterns lead to more organized, flexible, and easier-to-change codebases.
  4. TypeScript’s Edge: TypeScript allows us to define the structure and contracts of these patterns with explicit types. This means that if you’re implementing a Singleton, TypeScript can help ensure you’ve correctly followed its rules, catching errors before your code even runs! This dramatically reduces bugs and improves developer experience.

We’ll start with two fundamental patterns: the Singleton Pattern and the Factory Pattern.


The Singleton Pattern: Ensuring One and Only One

Have you ever needed to make sure that only one instance of a particular class exists throughout your entire application? Think about a global configuration manager, a logging service, or a database connection pool. You wouldn’t want multiple loggers writing to the same file haphazardly, or multiple database connection pools competing for resources. This is where the Singleton Pattern shines!

Core Concept: One Instance to Rule Them All

The Singleton pattern ensures that a class has only one instance and provides a global point of access to that instance.

How it functions:

  • It has a private constructor to prevent direct instantiation (you can’t just new MySingleton()).
  • It holds a static reference to its single instance.
  • It provides a static public method (often called getInstance()) that returns the single instance, creating it if it doesn’t already exist.

Why it matters in TypeScript: TypeScript’s access modifiers (private, public, protected) and static members are perfectly suited to enforce the Singleton’s rules at compile-time, giving us strong guarantees.

Step-by-Step Implementation: Building a Configuration Manager Singleton

Let’s build a ConfigurationManager that reads settings once and provides them globally.

First, create a new file named singleton.ts.

// singleton.ts

// Step 1: Define what our configuration will look like
interface AppConfig {
    apiUrl: string;
    port: number;
    debugMode: boolean;
}

// Now, let's start building our ConfigurationManager class.
// For now, it's just a regular class.
class ConfigurationManager {
    private config: AppConfig;

    constructor() {
        // Imagine this loads configuration from a file or environment variables
        console.log("ConfigurationManager: Initializing configuration...");
        this.config = {
            apiUrl: "https://api.example.com/v1",
            port: 3000,
            debugMode: true,
        };
    }

    getConfig(): AppConfig {
        return this.config;
    }

    setDebugMode(mode: boolean): void {
        this.config.debugMode = mode;
        console.log(`Debug mode set to: ${this.config.debugMode}`);
    }
}

Explanation:

  • We defined an AppConfig interface to clearly specify the shape of our application’s configuration. This is pure TypeScript goodness, providing type safety for our settings!
  • The ConfigurationManager class currently has a config property and a constructor that initializes it. It also has methods to getConfig and setDebugMode.
  • Right now, you could create multiple instances of ConfigurationManager with new ConfigurationManager(), which is not what we want for a Singleton.

Now, let’s transform ConfigurationManager into a true Singleton.

// singleton.ts

interface AppConfig {
    apiUrl: string;
    port: number;
    debugMode: boolean;
}

class ConfigurationManager {
    // Step 2: Add a static private instance property
    // This will hold the one and only instance of our class.
    private static instance: ConfigurationManager;

    private config: AppConfig;

    // Step 3: Make the constructor private!
    // This is the core of the Singleton pattern – no one can directly 'new' this class.
    private constructor() {
        console.log("ConfigurationManager: Initializing configuration...");
        this.config = {
            apiUrl: "https://api.example.com/v1",
            port: 3000,
            debugMode: true,
        };
    }

    // Step 4: Add a static public method to get the instance.
    // This is the *only* way to get an instance of ConfigurationManager.
    public static getInstance(): ConfigurationManager {
        // If the instance doesn't exist yet, create it.
        if (!ConfigurationManager.instance) {
            ConfigurationManager.instance = new ConfigurationManager();
        }
        // Always return the single instance.
        return ConfigurationManager.instance;
    }

    getConfig(): AppConfig {
        return this.config;
    }

    setDebugMode(mode: boolean): void {
        this.config.debugMode = mode;
        console.log(`Debug mode set to: ${this.config.debugMode}`);
    }
}

Explanation of changes:

  • private static instance: ConfigurationManager;: We added a static property named instance. static means it belongs to the class itself, not to any particular instance. private means it can only be accessed from within the ConfigurationManager class. It will store our single instance.
  • private constructor(): This is crucial! By making the constructor private, we prevent anyone outside the class from using new ConfigurationManager(). This ensures no other instances can be created directly.
  • public static getInstance(): ConfigurationManager: This static method is the designated gateway. When called, it first checks if ConfigurationManager.instance already exists.
    • If !ConfigurationManager.instance (it’s null or undefined), it means this is the first time getInstance is called. So, it creates the instance: ConfigurationManager.instance = new ConfigurationManager();. Notice that inside the class, we can call the private constructor.
    • Finally, it returns the stored ConfigurationManager.instance. This guarantees that every subsequent call to getInstance() will return the exact same object.

Using the Singleton

Now, let’s see our Singleton in action! Add the following code at the bottom of your singleton.ts file:

// singleton.ts (continued)

// Step 5: Using the Singleton
console.log("\n--- Using the Singleton ---");

const config1 = ConfigurationManager.getInstance();
console.log("Config 1:", config1.getConfig());

const config2 = ConfigurationManager.getInstance();
console.log("Config 2:", config2.getConfig());

// Let's modify the config using one instance
config1.setDebugMode(false);

// Now, check the config using the other instance
console.log("Config 2 after modification:", config2.getConfig());

// Let's verify if they are indeed the same instance
console.log("Are config1 and config2 the same instance?", config1 === config2);

// Try to create a new instance directly (this will cause a TypeScript error!)
// const directInstance = new ConfigurationManager(); // Uncommenting this line will show an error!

Run this code: Open your terminal, navigate to your project directory, and compile and run:

# Assuming you have ts-node installed for quick execution
# If not, you can compile first: npx tsc singleton.ts
# Then run: node singleton.js
npx ts-node singleton.ts

Expected Output:

ConfigurationManager: Initializing configuration...
--- Using the Singleton ---
Config 1: { apiUrl: 'https://api.example.com/v1', port: 3000, debugMode: true }
Config 2: { apiUrl: 'https://api.example.com/v1', port: 3000, debugMode: true }
Debug mode set to: false
Config 2 after modification: { apiUrl: 'https://api.example.com/v1', port: 3000, debugMode: false }
Are config1 and config2 the same instance? true

What to observe:

  • The “Initializing configuration…” message appears only once, even though we called getInstance() twice. This confirms that the constructor was called only for the first instance creation.
  • When config1 modifies debugMode, config2 immediately reflects that change because config1 and config2 are references to the exact same object in memory.
  • The config1 === config2 comparison evaluates to true, definitively proving it’s the same instance.
  • If you uncomment const directInstance = new ConfigurationManager();, TypeScript will give you a compile-time error like: Error: Constructor of class 'ConfigurationManager' is private and only accessible within the class declaration. This is TypeScript enforcing the Singleton pattern’s rules for you! Awesome, right?

The Factory Pattern: Delegating Object Creation

Sometimes, you need to create different types of objects, but you want to hide the complex logic of which specific class to instantiate. For example, in a game, you might need to create various types of enemies (Orc, Goblin, Dragon) based on the game level or player choices. The Factory Pattern helps here by centralizing object creation.

Core Concept: A Centralized Creation Hub

The Factory pattern provides an interface for creating objects in a superclass, but allows subclasses to alter the type of objects that will be created. Or, more simply, it’s a way to create objects without exposing the instantiation logic to the client.

How it functions:

  • It defines a common interface or abstract class for the objects that will be created.
  • It has concrete classes that implement this interface.
  • It provides a factory method (often within a factory class or as a standalone function) that takes some parameters and returns an instance of one of the concrete classes, ensuring it adheres to the common interface.

Why it matters in TypeScript: TypeScript’s interfaces and return type annotations are perfect for ensuring that the factory always produces objects of a specific, expected type, regardless of the underlying concrete class. This makes your code more flexible and less prone to errors when dealing with varied but related objects.

Step-by-Step Implementation: Building a Notification Factory

Let’s imagine we’re building a notification system that can send messages via Email, SMS, or Push Notifications.

Create a new file named factory.ts.

// factory.ts

// Step 1: Define a common interface for our notifications.
// All notification types must implement this contract.
interface INotification {
    send(message: string): void;
}

// Step 2: Implement Concrete Notification Classes.
// These are the actual types of notifications we can send.

class EmailNotification implements INotification {
    constructor(private recipient: string) {}

    send(message: string): void {
        console.log(`Sending Email to ${this.recipient}: "${message}"`);
        // Real-world: integrate with email service API
    }
}

class SMSNotification implements INotification {
    constructor(private phoneNumber: string) {}

    send(message: string): void {
        console.log(`Sending SMS to ${this.phoneNumber}: "${message}"`);
        // Real-world: integrate with SMS gateway API
    }
}

class PushNotification implements INotification {
    constructor(private deviceToken: string) {}

    send(message: string): void {
        console.log(`Sending Push Notification to device ${this.deviceToken}: "${message}"`);
        // Real-world: integrate with push notification service API
    }
}

Explanation:

  • INotification is our contract. Any class that implements INotification must have a send method that takes a string. This is fantastic for type safety!
  • EmailNotification, SMSNotification, and PushNotification are our concrete implementations. They all fulfill the INotification contract. Notice how their constructors take different parameters, reflecting the specific data needed for each notification type.

Now, let’s create our Notification Factory!

// factory.ts (continued)

// Step 3: Create the Notification Factory.
// This factory will decide which concrete notification class to instantiate.

// Define a type for the notification types our factory supports
type NotificationType = 'email' | 'sms' | 'push';

class NotificationFactory {
    public static createNotification(
        type: NotificationType,
        recipientDetail: string // This will be email address, phone number, or device token
    ): INotification {
        switch (type) {
            case 'email':
                return new EmailNotification(recipientDetail);
            case 'sms':
                return new SMSNotification(recipientDetail);
            case 'push':
                return new PushNotification(recipientDetail);
            default:
                // TypeScript helps here with exhaustiveness checking if we use a union type for NotificationType
                // But for robustness, we can throw an error for unknown types.
                throw new Error(`Unknown notification type: ${type}`);
        }
    }
}

Explanation of changes:

  • type NotificationType = 'email' | 'sms' | 'push';: We use a string literal union type to explicitly define the allowed type values for our factory. This provides excellent compile-time checking.
  • public static createNotification(...): This is our factory method. It’s static so we don’t need to new NotificationFactory() to use it.
  • It takes type (our NotificationType) and recipientDetail as arguments.
  • The switch statement checks the type and instantiates the appropriate concrete class (EmailNotification, SMSNotification, PushNotification).
  • Crucially, the return type is INotification. This means that no matter which concrete class is created, TypeScript guarantees that the returned object will always have a send(message: string): void method. This is the power of polymorphism and interfaces combined with TypeScript!

Using the Factory

Let’s put our NotificationFactory to work. Add the following code to the bottom of your factory.ts file:

// factory.ts (continued)

// Step 4: Using the Factory
console.log("\n--- Using the Notification Factory ---");

const emailNotifier: INotification = NotificationFactory.createNotification('email', '[email protected]');
emailNotifier.send("Hello from the TypeScript Factory!");

const smsNotifier: INotification = NotificationFactory.createNotification('sms', '+15551234567');
smsNotifier.send("Your order has shipped!");

const pushNotifier: INotification = NotificationFactory.createNotification('push', 'device-token-xyz-123');
pushNotifier.send("New message received!");

// Because of type safety, you can't accidentally call a method that doesn't exist on INotification
// emailNotifier.someOtherMethod(); // This would be a TypeScript error!

// What if we try to create an unknown type?
try {
    // @ts-ignore: We are intentionally causing an error for demonstration
    const unknownNotifier = NotificationFactory.createNotification('fax', '123-456-7890');
    unknownNotifier.send("This should not happen!");
} catch (error: any) {
    console.error(`\nError caught as expected: ${error.message}`);
}

Run this code:

npx ts-node factory.ts

Expected Output:

--- Using the Notification Factory ---
Sending Email to [email protected]: "Hello from the TypeScript Factory!"
Sending SMS to +15551234567: "Your order has shipped!"
Sending Push Notification to device device-token-xyz-123: "New message received!"

Error caught as expected: Unknown notification type: fax

What to observe:

  • We successfully created different types of notification objects without knowing their concrete classes directly. We just told the factory what kind of notification we needed.
  • The type annotation const emailNotifier: INotification ensures that emailNotifier is treated as an INotification at compile time, meaning we can only call methods defined in INotification (like send).
  • The try...catch block demonstrates our factory’s robustness for handling unsupported types, thanks to the throw new Error and TypeScript’s help in defining NotificationType.

Mini-Challenge: Build a Simple “Shape” Factory

It’s your turn to apply what you’ve learned!

Challenge: Create a simple “Shape” factory.

  1. Define an interface IShape with a method draw(): void.
  2. Implement two concrete classes: Circle and Rectangle, both implementing IShape.
    • Circle’s constructor should take a radius: number.
    • Rectangle’s constructor should take width: number and height: number.
    • Their draw methods should simply log a message describing the shape and its dimensions (e.g., “Drawing a Circle with radius 5”).
  3. Create a ShapeFactory class with a static method createShape(type: 'circle' | 'rectangle', ...args: number[]): IShape.
    • The factory method should return the correct shape based on the type string. You’ll need to use the ...args to pass the radius, width, or height dynamically.
  4. Use your ShapeFactory to create and draw a Circle and a Rectangle.

Hint: Think about how you passed recipientDetail to the NotificationFactory. For ...args, you might need to use type assertions or check the args.length inside your factory to correctly assign parameters to the constructors. For example: new Circle(args[0]).

What to observe/learn: This challenge will reinforce your understanding of interfaces, concrete implementations, and how a factory centralizes object creation while maintaining type safety. You’ll also get a taste of handling varying constructor arguments.


Common Pitfalls & Troubleshooting

Even with TypeScript’s help, design patterns can be tricky. Here are a few common pitfalls:

  1. Overuse of Singletons:

    • Pitfall: Singletons create global state, which can make your application harder to test and lead to tight coupling between different parts of your code. They can become “god objects” that know too much.
    • Solution: Use Singletons sparingly and only when you genuinely need a single, globally accessible instance (e.g., a true global configuration, a core logging service). For many scenarios, dependency injection or passing instances around explicitly is a more flexible approach. Always ask: “Does this really need to be a single instance, or could there be multiple if the application grew?”
    • TypeScript Tip: TypeScript helps enforce the structure of a Singleton, but it can’t tell you if it’s the right design choice for your problem. That’s up to you as the architect!
  2. Forgetting private Constructor in Singleton:

    • Pitfall: If you forget to make the constructor private, other parts of your code can directly instantiate the class using new MySingleton(), breaking the “single instance” guarantee of the pattern.
    • Solution: Always ensure your Singleton’s constructor is marked private. TypeScript will immediately give you a compile-time error if you try to new it from outside the class, which is a huge benefit!
  3. Not Defining a Common Interface in Factory:

    • Pitfall: If your concrete products (like EmailNotification, SMSNotification) don’t implement a common interface (INotification), your factory method’s return type might have to be any or a complex union type, losing the benefits of type safety. You wouldn’t be able to confidently call product.send() without checking its type first.
    • Solution: Always define a clear interface that all products created by your factory must adhere to. This allows your factory method to return that interface type, guaranteeing that any object it produces will have the expected methods and properties. This makes your code much more predictable and easier to refactor.

Summary

Phew, you’ve taken a significant leap today into the world of software architecture!

Here’s a quick recap of what we’ve covered in this chapter:

  • Design Patterns are proven solutions to common software design problems, offering a shared vocabulary and promoting maintainable, scalable code.
  • TypeScript significantly enhances design patterns by enforcing their rules and contracts at compile-time, catching errors early.
  • The Singleton Pattern ensures that a class has only one instance and provides a global access point. We built a ConfigurationManager to demonstrate this.
  • Key elements of a Singleton are a private constructor and a public static getInstance() method.
  • The Factory Pattern provides an interface for creating objects, allowing you to centralize object creation logic and return various concrete types that adhere to a common interface. We built a NotificationFactory as an example.
  • Key elements of a Factory are a common interface for products and a factory method to create them.
  • We discussed common pitfalls like the overuse of Singletons and the importance of interfaces in the Factory pattern.

By understanding and applying these patterns, you’re not just writing code; you’re designing software with foresight and intention. You’re building applications that are easier to understand, extend, and maintain.

In the next chapter, we’ll continue our journey into advanced TypeScript topics, perhaps exploring other powerful patterns or diving deeper into generic programming techniques that further solidify your architectural prowess. Keep practicing, keep building, and keep being awesome!