Applying Singleton, Builder, Composite, and Abstract Factory Patterns for Smart Home Devices in TypeScript

In this lesson, we will integrate design patterns into a practical project: building a smart home system. You'll learn how to create and adapt various smart home devices using the Singleton, Builder, Composite, and Abstract Factory patterns in TypeScript. By the end, you will have a solid understanding of how these design patterns, combined with TypeScript's type system, can make your smart home system more efficient, modular, and easier to maintain.

Quick Summary
  1. Singleton Pattern:

    • Purpose: Ensures a class has only one instance and provides a global point of access to it.
    • TypeScript Features: Use private constructors, static properties, and type annotations to enforce singleton behavior.
  2. Builder Pattern:

    • Purpose: Constructs complex objects step by step, providing a flexible solution for object creation.
    • TypeScript Features: Use classes, interfaces, and method chaining with type annotations for clarity and safety.
  3. Composite Pattern:

    • Purpose: Treats individual objects and compositions of objects uniformly.
    • TypeScript Features: Use interfaces or abstract classes to define a common contract for components and leverage type safety for composite structures.
  4. Abstract Factory Pattern:

    • Purpose: Provides an interface for creating families of related or dependent objects without specifying their concrete classes.
    • TypeScript Features: Use abstract classes or interfaces to define factories and product types, ensuring type-safe object creation.
Implementing the Singleton Pattern

To start, we implement the Singleton pattern in TypeScript to ensure that a class has only one instance and provides a global point of access to it. TypeScript's private constructors and static properties help enforce this pattern.

class Singleton {
    private static instance?: Singleton;

    // Private constructor prevents direct instantiation
    private constructor() {
        // Additional initialization code can go here
    }

    public static getInstance(): Singleton {
        if (Singleton.instance === undefined) {
            Singleton.instance = new Singleton();
        }
        return Singleton.instance;
    }
}

// Usage
const singleton1 = Singleton.getInstance();
const singleton2 = Singleton.getInstance();
console.log(singleton1 === singleton2);  // Output: true

The instance property is now typed as Singleton | undefined, and the check uses undefined, which is more idiomatic in TypeScript.

Constructing Devices with the Builder Pattern

Next, we use the Builder pattern to construct complex SmartHomeDevice objects step by step. TypeScript interfaces and type annotations make the process clear and type-safe.

interface ISmartHomeDevice {
    sensors: string[];
    actuators: string[];
    name: string;
}

class SmartHomeDevice implements ISmartHomeDevice {
    sensors: string[] = [];
    actuators: string[] = [];
    name: string = "";
}

class SmartHomeDeviceBuilder {
    private device: SmartHomeDevice;

    constructor() {
        this.device = new SmartHomeDevice();
    }

    public addSensors(sensors: string[]): this {
        this.device.sensors = sensors;
        return this;
    }

    public addActuators(actuators: string[]): this {
        this.device.actuators = actuators;
        return this;
    }

    public setName(name: string): this {
        this.device.name = name;
        return this;
    }

    public build(): SmartHomeDevice {
        return this.device;
    }
}

// Usage
const builder = new SmartHomeDeviceBuilder();
const device = builder
    .setName('Smart Light')
    .addSensors(['Light Sensor'])
    .addActuators(['LED'])
    .build();

console.log(device.name);  // Output: Smart Light

The SmartHomeDeviceBuilder class provides methods to set device properties and construct the final SmartHomeDevice, with all types enforced by TypeScript.

Managing Composition of Devices with Composite Pattern

Now, we implement the Composite pattern to manage individual and composite objects uniformly. TypeScript interfaces and type annotations help define the structure and ensure type safety.

interface DeviceComponent {
    name: string;
    operation(): void;
}

class LeafDevice implements DeviceComponent {
    constructor(public name: string) {}

    public operation(): void {
        console.log(`Leaf ${this.name} operation`);
    }
}

class CompositeDevice implements DeviceComponent {
    private children: DeviceComponent[] = [];

    constructor(public name: string) {}

    public add(component: DeviceComponent): void {
        this.children.push(component);
    }

    public operation(): void {
        console.log(`Composite ${this.name} operation`);
        this.children.forEach(child => child.operation());
    }
}

// Usage
const leaf1 = new LeafDevice('Light');
const leaf2 = new LeafDevice('Fan');
const composite = new CompositeDevice('Room');
composite.add(leaf1);
composite.add(leaf2);
composite.operation();
// Output:
// Composite Room operation
// Leaf Light operation
// Leaf Fan operation

The CompositeDevice class allows us to treat individual DeviceComponent objects uniformly as part of a composite structure, with all types enforced by TypeScript.

Creating Device Families with Abstract Factory Pattern

Finally, we implement the Abstract Factory pattern to create families of related objects without specifying their concrete classes. TypeScript's abstract classes and interfaces help define clear contracts for factories and products.

// Product interfaces
interface Sensor {
    // Additional sensor methods can be defined here
}

interface Actuator {
    // Additional actuator methods can be defined here
}

// Concrete products
class LightSensor implements Sensor {
    constructor() {
        console.log('Light Sensor created');
    }
}

class LEDActuator implements Actuator {
    constructor() {
        console.log('LED Actuator created');
    }
}

class TemperatureSensor implements Sensor {
    constructor() {
        console.log('Temperature Sensor created');
    }
}

class MotorActuator implements Actuator {
    constructor() {
        console.log('Motor Actuator created');
    }
}

// Abstract factory
abstract class SmartDeviceFactory {
    abstract createSensor(): Sensor;
    abstract createActuator(): Actuator;
}

// Concrete factories
class LightFactory extends SmartDeviceFactory {
    public createSensor(): Sensor {
        return new LightSensor();
    }

    public createActuator(): Actuator {
        return new LEDActuator();
    }
}

class FanFactory extends SmartDeviceFactory {
    public createSensor(): Sensor {
        return new TemperatureSensor();
    }

    public createActuator(): Actuator {
        return new MotorActuator();
    }
}

// Usage for LightFactory
const lightFactory = new LightFactory();
const lightSensor = lightFactory.createSensor();  // Output: Light Sensor created
const lightActuator = lightFactory.createActuator();  // Output: LED Actuator created

// Usage for FanFactory
const fanFactory = new FanFactory();
const fanSensor = fanFactory.createSensor();  // Output: Temperature Sensor created
const fanActuator = fanFactory.createActuator();  // Output: Motor Actuator created

The LightFactory and FanFactory classes implement methods to create specific sensor and actuator objects, with all types and contracts enforced by TypeScript.

Conclusion

Implementing Singleton, Builder, Composite, and Abstract Factory patterns in our smart home system using TypeScript allows us to create, organize, and manage devices in a structured and reusable manner. TypeScript's type safety, interfaces, and abstract classes help prevent errors and make the codebase more robust and maintainable. This approach makes our smart home system more modular, flexible, and easier to extend, while leveraging the full power of TypeScript's static typing.

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal