Skip to content
sang.id.vn
Go back

Behavioral Design Patterns Explained with TypeScript

Tiếng Việt

Behavioral patterns deal with how objects talk to each other. If you’ve ever written a mess of callbacks, observer lists, or state machines held together by duct tape — these patterns give that mess a name and a structure. Here are 9 that I keep reaching for.

1. Chain of Responsibility

Allows us to handle events using one or more Handlers

I think of Chain of Responsibility as a relay race for your requests. You turn event-receiving objects into standalone handler objects, then link them into a chain. When a request comes in, it hops from one handler to the next until somebody picks it up and deals with it. Each handler gets to decide: “I got this” or “pass it along.”

The real win here? It breaks a single bloated request handler into small, focused pieces. If you’ve ever stared at a wall of nested if..else statements and wanted to scream, this pattern is for you. Each handler owns one piece of logic under one specific condition. No more tangled conditionals. And the sender never needs to know which handler will ultimately do the work.

1.1. When to use Chain of Responsibility?

1.2. Structure

Components participating in Chain of Responsibility Pattern:

1.3. Implementation

interface Handler {
    setNext(handler: Handler): void;
    handle(request: Request): any;
}
class BaseHandler implements Handler {
    protected next?: Handler;
    init(next?: Handler) {
        this.next = next
    }
    setNext(handler?: Handler) : void {
        this.next = handler
    }
    handle(request: Request) : any {
        if(this.next!=null)
            return this.next.handle(request);
    }
}
class ConcreteHandler extends BaseHandler {
    protected next?: Handler;
    init(next?: Handler) {
        this.next = next
    }
    setNext(handler?: Handler) : void {
        this.next = handler
    }
    handle(request: Request) : any {
        if(this.canHandle(request)) {
            // Code here
        } else {
            if(this.next!=null)
                return this.next.handle(request);
        }
    }
    canHandle(request: any) : boolean {
        // return True or False
    }
}
class Client {
  sendRequest() {
    let thirdHandler = new ConcreteHandler(null);
    let secondHandler = new ConcreteHandler(thirdHandler);
    let firstHandler = new ConcreteHandler(secondHandler);
    let request = new Request();
    firstHandler.handle(request);
  }
}

2. Command

Command Pattern (also known as Action Pattern or Transaction Pattern) creates an object that can execute a specific method on another input object without needing to know the properties of that input object

2.1. Problem

Say you’re building an e-commerce site. Every week it sends out messages about best-selling products — via email or SMS, depending on what the user chose in their settings. Pick email, no SMS. Pick SMS, no email. So how do you build one object that can push a message through two completely different channels with different logic?

Or picture this: you’re building SlickUI, a GUI framework. You’ve made gorgeous buttons, slick dialogs, beautiful icons. But then it hits you — how do people actually do things with these components? You hope thousands of developers will use SlickUI, spawning millions of SlickButton instances. The obvious answer is inheritance — make a subclass for each button behavior. But a real GUI app might have hundreds of buttons. Hundreds of subclasses? And that’s just buttons — what about menu items, radio buttons? That approach falls apart fast.

2.2. Solution

The answer is to package up the action — what happens when a button gets clicked or a menu item gets selected — into its own object. Pull that handling code out and wrap it in a separate object. These action objects are the commands in Command Pattern.

2.3. When to use Command Pattern?

2.4. Structure

Components participating in Command Pattern:

2.5. Implementation

class Light {
    public light: string = "light";
}
interface Command {
    execute(): any;
}
class CommandOn implements Command {
    private object?: Light;
    constructor(object?: Light) {
        this.object = object;
    }
    execute() {
        console.log(this.object?.light + ' on')
    }
}
class CommandOff implements Command {
    private object?: Light;
    constructor(object?: Light) {
        this.object = object;
    }
    execute() {
        console.log(this.object?.light + ' off')
    }
}
class RemoteControl {
    private command?: Command;
    setCommand(command: Command) {
        this.command = command
    }
    run() {
        this.command?.execute()
    }
}
let remote = new RemoteControl();
remote.setCommand(new CommandOn(new Light()));
remote.run(); // light on

3. Mediator

Provides an intermediary class responsible for handling communication between classes

This one uses many-to-many relationships between similar objects to reach a “full object” state. I find Mediator especially useful when components keep stepping on each other’s toes.

3.1. Problem

You want reusable components. Great. But when those components depend on each other, you end up with spaghetti code.

“Spaghetti code” — we’ve all seen it. Complex, tangled control flow full of GOTO statements, exceptions, threads, or other unstructured branching. The name fits: the program flow looks like a bowl of spaghetti, twisted and knotted.

It usually happens when multiple people with different coding styles keep modifying the same codebase over time. Structured programming helps a lot, but sometimes you need a pattern to enforce order.

3.2. Solution

That’s where Mediator comes in. Instead of letting Components talk directly to each other, you route everything through a Mediator object. The Mediator receives events from different Components and handles them. Think of it as a central dispatcher — one place that coordinates all the cross-component chatter.

3.3. When to use Mediator?

3.4. Example

The airport control tower is the classic Mediator example, and I think it nails the concept perfectly. Pilots don’t talk to each other directly — they talk to the tower. The tower decides who takes off, who lands, and in what order. But it doesn’t control entire flights. It only exists to enforce safety during takeoff and landing. That’s exactly what a Mediator does: it coordinates, it doesn’t micromanage.

3.5. Structure

Components participating in Mediator Pattern:

3.6. Implementation

abstract class BaseComponent {
    protected mediator?: Mediator
    constructor(mediator?: Mediator) {
        this.mediator = mediator
    }
    update(mediator?: Mediator) {
        this.mediator = mediator
    }
}

class Component1 extends BaseComponent {
    constructor(mediator?: Mediator) {
        super(mediator)
    }
    update(mediator?: Mediator) {
        super.update(mediator)
    }
    doA() {
        console.log("Component 1 does A.")
        this.mediator?.notify("A")
    }
    doB() {
        console.log("Component 1 does B.\n")
        this.mediator?.notify("B")
    }
}

class Component2 extends BaseComponent {
    constructor(mediator?: Mediator) {
        super(mediator)
    }
    update(mediator?: Mediator) {
        super.update(mediator)
    }
    doC() {
        console.log("Component 1 does C")
        this.mediator?.notify("C")
    }
    doD() {
        console.log("Component 1 does D")
        this.mediator?.notify("D")
    }
}
interface Mediator {
    notify(event: String): void
}

class ConcreteMediator implements Mediator {
    constructor(...components: BaseComponent[]) {
        for(let component of components) {
            component.update(this)
        }
    }
    updateMediator(component: BaseComponent) {
        component.update(this)
    }
    notify(event: String) {
        console.log(`Mediator reacts on ${event}`)
    }
}
let component1 = new Component1();
let component2 = new Component2();
let mediator = new ConcreteMediator(component1, component2);

component1.doA();
console.log("\n");
component2.doC();
Component 1 does A.
Mediator reacts on A

Component 1 does C
Mediator reacts on C

4. Memento

Allows us to save and restore the state of an object without revealing its internal details

4.1. When to use Memento Pattern?

I reach for Memento whenever I need save/restore functionality. Think video games — you save your progress, quit, come back later, and pick up where you left off. Or any app that needs Undo/Redo: you snapshot an object’s state, stash it somewhere external, and roll back when needed. It’s also a natural fit for anything that needs transaction management.

4.2. How it works

Memento structures the data you want to save from an Object into a State, then persists that State. The saved States are called Mementos. The CareTaker is responsible for storing States as Mementos and handing them back as States when you need them. Because the object’s internals are stored in State, passing that State around never exposes the object’s implementation details. Clean separation.

4.3. Advantages

4.4. Disadvantages

4.5. Structure

Components participating in Memento Pattern:

4.6. Example

class Memento {
    constructor(private readonly state: string) { }
    getSavedState(): string {
        return this.state
    }
}
class Originator {
    private state!: string
    set(state: string): void {
        console.log("Originator: Setting state to " + state)
        this.state = state
    }
    saveToMemento(): Memento {
        console.log("Originator: Saving to Memento.")
        return new Memento(this.state)
    }
    restoreFromMemento(memento: Memento): void {
        this.state = memento.getSavedState()
        console.log("Originator: State after restoring from Memento: " + this.state)
    }
}
 // CareTaker
let savedStates : Array<Memento> = new Array<Memento>()
originator.set("State #1");
originator.set("State #2");
savedStates.push(originator.saveToMemento());
originator.set("State #3");
savedStates.push(originator.saveToMemento());
originator.set("State #4");

// Restore the oldest saved state
originator.restoreFromMemento(savedStates[0]);
Originator: Setting state to State #1
Setting state to State #2
Saving to Memento.
Setting state to State #3
Saving to Memento.
Setting state to State #4
State after restoring from Memento: State #2

5. Observer

An object, called the subject, maintains a list of its dependents, called observers, and automatically notifies them of any state changes, usually by calling one of their methods

5.1. Problem

Picture an Excel spreadsheet with multiple worksheets feeding data into charts. You can create as many charts as you want from those worksheets. Now change a value in one worksheet — every chart using that data needs to update. And there’s no limit to how many charts might depend on a single worksheet.

5.2. Solution

Observer Pattern handles this cleanly. The worksheet is the subject, the charts are observers. When worksheet data changes, it notifies all dependent charts automatically. Done.

5.3. When to use Observer Pattern?

5.4. Structure

Components participating in Observer Pattern:

5.5. Implementation

interface Observer {
    update(mesage: string): void
}

class ConcreteObserver implements Observer {
    constructor(private beforeMessage: string) {}
    update(message: string) {
        console.log(this.beforeMessage + " " + message)
    }
}
interface Subject {
    observers : Array<Observer>
    attach(observer: Observer) : void
    detach(observer: Observer): void
    notifyChange(message: string): void
}
class ConcreteSubject implements Subject {
    observers: Array<Observer> = new Array<Observer>()
    attach(observer: Observer): void {
        this.observers.push(observer)
    }
    detach(observer: Observer): void {
        this.observers.splice(this.observers.indexOf(observer), 1)
    }
    notifyChange(message: string): void {
        for (let observer of this.observers) {
            observer.update(message)
        }
    }
}
let subject : Subject = new ConcreteSubject()
let observer1 : Observer = new ConcreteObserver("Message 1 updated:")
let observer2 : Observer = new ConcreteObserver("Message 2 updated:")
subject.attach(observer1)
subject.attach(observer2)

subject.notifyChange('Subject notify!')
subject.detach(observer1)
console.log("Removed Observer 1\n")
subject.notifyChange('Subject notify!')
Message 1 updated: Subject notify!
Message 2 updated: Subject notify!
Removed Observer 1
Message 2 updated: Subject notify!

6. Strategy

Allows us to define business logic as different objects that can be swapped at runtime

6.1. When to use Strategy Pattern?

This is one of my most-used patterns. Reach for it whenever you have two or more behaviors that need to be interchangeable at runtime. Instead of hard-coding which algorithm runs, you let the caller decide by injecting the right strategy.

6.2. Structure

Components participating in Strategy Pattern:

6.3. Implementation

interface Strategy {
    doOperation(num1: number, num2: number): number
}
class OperationAdd implements Strategy {
    doOperation(num1: number, num2: number): number {
        return num1 + num2
    }
}
class OperationSubstract implements Strategy {
    doOperation(num1: number, num2: number) : number {
        return num1 - num2
    }
}
class OperationMultiply implements Strategy {
    doOperation(num1: number, num2: number) : number {
        return num1 * num2
    }
}
class Context {
    constructor(private strategy: Strategy) { }
    executeStrategy(num1: number, num2: number) : number {
        return this.strategy.doOperation(num1, num2)
    }
}
let context : Context = new Context(new OperationAdd())
console.log("10 + 5 = " + context.executeStrategy(10, 5))

context = new Context(new OperationSubstract())
console.log("10 - 5 = " + context.executeStrategy(10, 5))

context = new Context(new OperationMultiply())
console.log("10 * 5 = " + context.executeStrategy(10, 5))
10 + 5 = 15
10 - 5 = 5
10 * 5 = 50

7. Visitor

Allows changing and extending operations on an object without modifying its internal structure or content

Here’s the idea: objects (Elements) pull their operations out into separate methods defined on dedicated Visitor classes. This decouples the operations from the object’s structure, making changes way more flexible.

Every time you add a new operation, you create a new visitor class. That’s it — the original objects stay untouched.

There’s also a neat side benefit: Visitor recovers type information that would otherwise be lost from passed arguments. It dispatches the right method based on the types of both the calling object and the argument. This is called Double Dispatch.

7.1. What are Double Dispatch and Single Dispatch?

class TestClass {
    testMethod(param: string) {
        console.log(param)
    }
}
new TestClass().testMethod("Hello World")
class Visitor {
    visit(element: Element) {
        console.log(element.hello())
    }
}
class Element {
    hello() {
        return "Xin chào"
    }
    accept(Visitor: visitor) {
        visitor.visit(this)
    }
}
new Element().accept(new Visitor())

7.2. Advantages

7.3. When to use Visitor Pattern?

7.4. Structure

Visitor Pattern

Components participating in Visitor Pattern:

7.5. Example

I like this example because it’s silly but it gets the point across. You’re a ladykiller. You want to confess your love to a girl, but you don’t know her nationality. Obviously you can’t say “anh yeu em” to a Japanese girl — she won’t understand. You’d say “Aishite imasu” instead. So we write a shared function sayLove() that picks the right words based on each lady’s nationality.

interface Lady {
    sayLove(): void;
}

class AmericanLady implements Lady {
    sayLove(): void {
        console.log("I love you");
    }
}

class JapanLady implements Lady {
    sayLove(): void {
        console.log("Aishite imasu");
    }
}

let lady : Lady = new JapanLady();
lady.sayLove(); // Result: Aishite imasu

Now here’s where it gets annoying. Want to add sayGoodBye()? You’d have to add it to the Lady interface, then implement it in every single class that extends it. Time-consuming and risky. This is exactly where Visitor Pattern shines.

interface Lady {
    accept(visitor: Visitor): void
}

class AmericanLady implements Lady {
    accept(visitor: Visitor): void {
        visitor.visit(this)
    }
}

class JapanLady implements Lady {
    accept(visitor: Visitor): void {
        visitor.visit(this)
    }
}
interface Visitor {
    visit(lady: Lady): void
}

class SayLoveVisitor implements Visitor {
    visit(lady: Lady): void {
        if (lady instanceof AmericanLady)
            console.log('I love you')
        if (lady instanceof JapanLady)
            console.log('Aishite imasu')
    }
}
let lady: Lady = new AmericaLady()
lady.accept(new SayLoveVisitor()) // Result: I love you
class SayGoodByeVisitor implements Visitor {
    visit(lady: Lady): void {
        if (lady instanceof AmericanLady)
            console.log('Good bye!')
        if (lady instanceof JapanLady)
            console.log('Sayounara!')
    }
}
let lady: Lady = new JapanLady()
lady.accept(new SayGoodByeVisitor()) // Result: Sayounara!

7.6. Conclusion

When you want to extend a ConcreteElement’s operations, you only update the Visitor — the ConcreteElement stays untouched. Open/Closed Principle satisfied.

The biggest downside? Visitor doesn’t handle extending Elements well. Adding a new Element means updating every single Visitor interface and class. That said, you can work around this with careful pattern adjustments and some clever data structure tweaks.

8. State

Allows an object to change its behavior when its internal state changes at runtime

8.1. Advantages

8.2. When to use State Pattern?

I see State Pattern a lot in systems that cycle through multiple states during their lifecycle. The number of states can be fixed or open-ended. Traffic lights are the textbook example: “red,” “yellow,” “green” — constantly rotating. It’s also a great cure for deeply nested if-else chains that try to manage state transitions manually.

8.3. Structure

Components participating in State Pattern:

8.4. How it works

8.5. Workflow

8.6. Example

We define a State interface with 2 concrete states: LowerCaseState and MultipleUpperCaseState. One prints lowercase, the other uppercase. They swap back and forth automatically.

interface State {
    writeName(context: StateContext, name: string): void;
}

class LowerCaseState implements State {
    writeName(context: StateContext, name: string) {
        console.log(name.toLowerCase());
        context.setState(new MultipleUpperCaseState());
    }
}

class MultipleUpperCaseState implements State {
    private count: number = 0;
    writeName(context: StateContext, name: string): void {
        console.log(name.toUpperCase());
        /* Change state after StateMultipleUpperCase's writeName() gets invoked twice */
        if (++this.count > 1) {
            context.setState(new LowerCaseState());
        }
    }
}

The Context class holds a state variable — the current state. It gets a default state on construction. There’s also a setter to swap states whenever a behavior is performed. The behavior here is writeName().

class StateContext {
    private state!: State;
    constructor() {
        this.state = new LowerCaseState();
    }
    setState(newState: State): void {
        this.state = newState;
    }
    writeName(name: string): void {
        this.state.writeName(this, name);
    }
}

Usage

monday
TUESDAY
WEDNESDAY
thursday
FRIDAY
SATURDAY
sunday

9. Repository

Repository Pattern is an intermediary layer between data access and logic processing, making data access more secure and structured

9.1. Structure

Repository Pattern sits between your Data Access and Business Logic layers. It makes data access cleaner and more maintainable.

Without it, you’d just write a Controller that queries the database directly. With Repository Pattern, you put a Repository between Controller and Model. The flow is simple: request hits Controller, Controller calls Repository, Repository calls Model to fetch and process data. The Controller never touches the database directly.

9.2. Advantages

9.3. Example

getPost() {
    let posts = Post.orderBy('id', 'desc').get();
    return posts;
}

Simple enough. But what happens when the client wants posts sorted alphabetically? You dig into the function and rewrite it. Table structure changes? You’re forced to update getPost() along with a hundred other methods. Worst case, management wants a different database entirely and you’re rewriting everything. Repository Pattern saves you from all of that.

You create a PostRepository class, maybe in a separate Repositories directory. All your data-fetching logic lives there. When requirements change, you update one file.

class PostRepository {
  getPostById() {
    return Post.orderBy("id", "desc").get();
  }
}

Now your PostController just calls the repository:

import { PostRepository } from './../Repositories';
class PostController extends Controller {
    constructor(private postRepository: PostRepository) { }
    getPost() {
        let posts = this.postRepository.getPostById();
        return posts;
    }
}

Share this post on:

Previous Post
Design Pattern Overview — Series Introduction
Next Post
Creational Design Patterns Explained with TypeScript