Behavioral Patterns in TypeScript

Alberto Basalo
14 min readMar 23, 2023

--

How do objects behave? At runtime, their life is about instantiating, calling, and being part of processes bigger than themselves. It is a complex and critical matter involving our programs’ flow control and data manipulation. Behavioral patterns guide us on how instances communicate with others.

Well-designed applications end up with dozens of classes. Some are like legacy-sealed stones, while others seem living creatures evolving. Sometimes we must deal with complex subsystems or add complexity to a simple buddy.

This article will show how to solve these situations with four behavioral patterns with examples in TypeScript. Let’s dive in!

🎒 Prerequisites

To complete this tutorial, you will need the following:

  • A local development environment for TypeScript
  • Basic knowledge of Object-Oriented Programming

Part of a series about Design Patterns with TypeScript.

🎖️ The Strategy Pattern.

Decouples third-party libraries (or legacy code) from the application

The problem

class Logger {
log(entry: LogEntry): string {
const message = this.getMessage(entry);
// ! 😱 new log levels require code changes
switch (entry.level) {
case "info":
console.log(message);
break;
case "debug":
console.debug(message);
break;
case "warn":
console.warn(message);
break;
case "error":
console.error(message);
break;
default:
console.error(message);
}
return message;
}
private getMessage(entry: LogEntry) {
// ! 😱 repeated dirty code
switch (entry.level) {
case "info":
return `💁🏼‍♂️: ${entry.message}`;
case "debug":
return `🐾: ${entry.message}`;
case "warn":
return `⚠️: ${entry.message}`;
case "error":
return `💣: ${entry.message} at ${new Date().toISOString()}}`;
default:
return `Unknown log level: ${entry.level}`;
}
}
}

class App {
private logger = new Logger();

public run() {
this.logger.log({ level: "info", message: "App started!" });
this.logger.log({ level: "debug", message: "I was here" });
this.logger.log({ level: "warn", message: "Heads up" });
this.logger.log({ level: "error", message: "Fatal exception" });
}
}

Sometimes there are several ways to execute a business process. You must code all those situations, and your program will choose one at runtime. That decision should be encapsulated and never repeated in the code.

The solution with the Strategy pattern

// * ✅ Strategy solution

export type LogLevel = "info" | "debug" | "warn" | "error";
export type LogEntry = { level: LogLevel; message: string };

// * 😏 an interface is a contract
interface LogStrategy {
log(entry: LogEntry): string;
}

// * 😏 different strategies implement the contract

export class InfoLogStrategy implements LogStrategy {
log(entry: LogEntry) {
const message = `💁🏼‍♂️: ${entry.message}`;
console.log(message);
return message;
}
}

export class DebugLogStrategy implements LogStrategy {
log(entry: LogEntry) {
const message = `🐾: ${entry.message}`;
console.debug(message);
return message;
}
}

export class WarnLogStrategy implements LogStrategy {
log(entry: LogEntry) {
const message = `⚠️: ${entry.message}`;
console.warn(message);
return message;
}
}

export class ErrorLogStrategy implements LogStrategy {
log(entry: LogEntry) {
const message = `💣: ${entry.message} at ${new Date().toISOString()}}`;
console.error(message);
return message;
}
}

// * 😏 also the context implements the contract

export class Logger implements LogStrategy {
// * 😏 a map of strategies (implicit factory)
static strategies = new Map<LogLevel, LogStrategy>([
["info", new InfoLogStrategy()],
["warn", new WarnLogStrategy()],
["error", new ErrorLogStrategy()],
]);

log(entry: LogEntry) {
const strategy = Logger.strategies.get(entry.level);
if (strategy) {
return strategy.log(entry);
} else {
const message = `Unknown log level: ${entry.level}`;
console.warn(message);
return message;
}
}
}

class App {
// * 😏 no body knows there are strategies
private logger = new Logger();

public run() {
this.logger.log({ level: "info", message: "App started!" });
// * 😏 but, if you know, you change the strategy map at runtime
Logger.strategies.set("debug", new DebugLogStrategy());
this.logger.log({ level: "debug", message: "I was here" });
this.logger.log({ level: "warn", message: "Heads up" });
this.logger.log({ level: "error", message: "Fatal exception" });
}
}

To do so, you can use the strategy pattern that requires creating an interface defining the contract of the process. Your code will depend on that abstraction. Then, you can create several implementations of that interface. Finally, you should use an intelligent factory (the strategy) and choose the concrete one at run time.

Strategies can be added or removed at runtime without modifying the code that uses them.

The strategy pattern respects the SOLID principles, Open/Closed by using interfaces, Interface Segregation depending on abstractions, and Dependency Inversion by choosing the implementations outside its consumers.

👁️ The Observer Pattern.

Decouples event emitters from event processors notifying changes to subscribers

The problem

// ! ❌ Bad example of not using an observable

// ! 😱 Agency depends on Logger and Payments
class Agency {
private bookings: object[] = [];
constructor(private logger: Logger, private payments: Payments) {}

addBooking(booking: object) {
this.bookings.push(booking);
// ! 😱 what if we want to send confirmation messages or anything else?
this.logger.log("info", `Booking created: ${JSON.stringify(booking)}`);
this.payments.pay(100);
}
}

// ! 😱 App is aware of Agency and its dependencies
export class App {
main() {
const logger = new Logger();
const payments = new Payments();
const agency = new Agency(logger, payments);
agency.addBooking({ trip: "Paris", price: 100 });
}
}

export class Logger {
log(category: string, data: string): void {
if (category === "err") console.error(data);
else console.log(data);
}
}

export class Payments {
pay(amount: number): void {
console.log(`Payment of ${amount} processed`);
}
}

Wherever you have objects, you have some communication between them. The simple way is by calling a method of the other object, called direct communication. The problem with this is that the objects are tightly coupled.

Things get worse when the caller needs not one but many objects to be notified of a change, the so-called broadcast communication.

The solution with the Observer pattern

// * ✅ Observer solution

// * 😏 the observer contract is a function used as a listener or callback
type Observer = (data: object) => void;

// * 😏 the observable contract is a set of methods to subscribe, unsubscribe and publish events
interface Observable {
subscribe(eventName: string, observer: Observer): void;
unsubscribe(eventName: string, observer: Observer): void;
publish(eventName: string, eventArgs: object): void;
}

export class Logger {
log(category: string, data: string): void {
if (category === "err") console.error(data);
else console.log(data);
}
}

export class Payments {
pay(amount: number): void {
console.log(`Payment of ${amount} processed`);
}
}

// * 😏 an event bus could be generic and reusable
export class EventBus implements Observable {
private subscriptions: Map<string, Observer[]> = new Map();

subscribe(eventName: string, observer: Observer): void {
let eventObservers = this.subscriptions.get(eventName);
if (!eventObservers) {
eventObservers = [];
this.subscriptions.set(eventName, eventObservers);
}
eventObservers.push(observer);
}

unsubscribe(eventName: string, observer: Observer): void {
const eventObservers = this.subscriptions.get(eventName);
if (!eventObservers) return;
const index = eventObservers.indexOf(observer);
eventObservers.splice(index, 1);
}

publish(eventName: string, eventArgs: object): void {
const eventObservers = this.subscriptions.get(eventName);
if (!eventObservers) return;
eventObservers.forEach((observer) => observer(eventArgs));
}
}

// * 😏 now you can extend or wrap the event bus
export class Agency extends EventBus {
private bookings: object[] = [];

addBooking(booking: object) {
this.bookings.push(booking);
this.publish("booking-created", booking);
}
addActivity(activity: object) {
this.publish("activity-created", activity);
}
}

export class App {
main() {
const agency = new Agency();
// * 😏 agency is now decoupled from Logger, Payments or whatever
agency.subscribe("booking-created", this.onBookingCreatedLog);
agency.subscribe("booking-created", this.onBookingCreatedPay);
agency.subscribe("activity-created", this.onActivityCreatedLog);
agency.addBooking({ trip: "Paris", price: 100 });
}
private onBookingCreatedLog(data: object): void {
const logger = new Logger();
logger.log("info", `Booking created: ${data}`);
}
private onActivityCreatedLog(data: object): void {
const logger = new Logger();
logger.log("warning", `Activity created: ${data}`);
}
private onBookingCreatedPay(data: object): void {
const payments = new Payments();
payments.pay((data as any).amount);
}
}

In the Observer pattern, communication is one-way. The objects that send notifications are called emitters or publishers. The objects that receive notifications are called observers or processors. The observers register with the emitters to be notified of messages (the arguments).

Between the emitter and the observer, there is an intermediate responsible for notifying the observers, keeping track of them, and notifying them when a message is received.

We can call it a subject when notifies changes in specific data or an event bus (or hub, aggregator, dispatcher) when notifies events which are pairs of name and data similar to method calls.

🪖 The Command Pattern.

We are used to programming business actions as methods of a class. This pattern elevates them to a higher category and dedicates a class for each business activity. In this way, the commands can be audited and reused in different contexts; even in some cases, you can override their effects

The problem

// ! ❌ Bad example of not using a command

// The invoker
export class EnrolmentController {
private service: EnrolmentService = new EnrolmentService();
private paymentService: PaymentService = new PaymentService();
// ! 😱 tight coupling invoker and receivers
enroll(activity: string, participant: string): void {
this.service.createEnroll(activity, participant);
this.paymentService.pay(activity, participant);
}
unEnrollment(activity: string, participant: string): void {
this.service.removeEnroll(activity, participant);
this.paymentService.refund(activity, participant);
}
}

// The receivers
export class EnrolmentService {
createEnroll(activity: string, participant: string): void {
console.log(`Enrolling ${participant} in ${activity}`);
}

removeEnroll(activity: string, participant: string): void {
console.log(`Un-enrolling ${participant} in ${activity}`);
}
}

export class PaymentService {
pay(activity: string, participant: string): void {
console.log(`Paying ${participant} for ${activity}`);
}
refund(activity: string, participant: string): void {
console.log(`Refunding ${participant} for ${activity}`);
}
}

Any request between objects (direct method callings) can be encapsulated as an object with a method to execute the command. This is the root of the Command pattern. You can enhance the functionality by adding parameters or methods to support the defer, undo, and redo executions.

The solution with the Command pattern

// * ✅ Command solution

// * 😏 The receivers remain the same, but...
export class EnrolmentService {
createEnroll(activity: string, participant: string): void {
console.log(`Enrolling ${participant} in ${activity}`);
}

removeEnroll(activity: string, participant: string): void {
console.log(`Un-enrolling ${participant} in ${activity}`);
}
}

export class PaymentService {
pay(activity: string, participant: string): void {
console.log(`Paying ${participant} for ${activity}`);
}
refund(activity: string, participant: string): void {
console.log(`Refunding ${participant} for ${activity}`);
}
}
// * 😏 use an interface and...
export interface Enrolment {
participant: string;
activity: string;
}
// * 😏 ... a Facade to wrap the receivers
export class EnrolmentFacade {
private service: EnrolmentService = new EnrolmentService();
private paymentService: PaymentService = new PaymentService();

enroll(enrollment: Enrolment): void {
this.service.createEnroll(enrollment.activity, enrollment.participant);
this.paymentService.pay(enrollment.activity, enrollment.participant);
}
unenroll(enrollment: Enrolment): void {
this.service.removeEnroll(enrollment.activity, enrollment.participant);
this.paymentService.refund(enrollment.activity, enrollment.participant);
}
}

// * 😏 The command interface

export interface Command<T> {
command: string;
payload: T;
execute(): void;
}

// * 😏 abstract command class specifying the receiver and type of payload
export abstract class AbstractCommand implements Command<Enrolment> {
protected receiver: EnrolmentFacade = new EnrolmentFacade();
abstract command: string;
abstract payload: Enrolment;
abstract execute(): void;
}

// * 😏 concrete commands calling the receiver

export class EnrollCommand extends AbstractCommand {
command = "Enroll";

constructor(public payload: Enrolment) {
super();
}

execute(): void {
super.receiver.enroll(this.payload);
}
}

export class UnenrollCommand extends AbstractCommand {
command = "Unenroll";

constructor(public payload: Enrolment) {
super();
}
execute(): void {
super.receiver.unenroll(this.payload);
}
}

export class CommandProcessor {
history: AbstractCommand[] = [];
dispatch(command: AbstractCommand): void {
console.log("CommandProcessor: Dispatching command", command);
command.execute();
this.history.push(command);
console.log("CommandProcessor: Command dispatched", command);
}
serialize(command: AbstractCommand): void {
console.log("CommandProcessor: Serializing command", command);
}
deserializeFactory(serialization: string): AbstractCommand {
const commandData = JSON.parse(serialization);
switch (commandData.command) {
case "Enroll":
return new EnrollCommand(commandData.payload);
case "Unenroll":
return new UnenrollCommand(commandData.payload);
default:
throw new Error("Unknown command");
}
}
}

export class Application {
main(): void {
const processor = new CommandProcessor();
const enrollCommand = new EnrollCommand({ participant: "John", activity: "surfing" });
const unenrollCommand = new UnenrollCommand({ participant: "John", activity: "surfing" });
processor.dispatch(enrollCommand);
processor.dispatch(unenrollCommand);
}
}

📚 The Template Pattern.

Ensure standard behavior and allow custom implementations

The problem

// ! ❌ Bad example of not using a template

export class EnrollmentService {
public enrol(activity: string): string {
if (activity === "") {
throw new Error("Activity name is required");
}
let businessResult = "";
try {
console.log("#️⃣ transaction started");
const paymentResult = "🤑 Paying Activity " + activity;
businessResult = "✍🏼 Booking Activity " + paymentResult;
console.log("#️⃣ action done");
const notification = "📧 Activity booked " + businessResult;
console.log("#️⃣ notification sent");
} catch (error) {
console.error("#️⃣ 😵‍💫 error: " + error);
}
return businessResult;
}

// ! 😱 repeated steps
public unenroll(activity: string): string {
if (activity === "") {
throw new Error("Activity name is required");
}
let businessResult = "";
try {
console.log("#️⃣ transaction started");
const refundResult = "💸 Refunding Activity " + activity;
businessResult = "😭 Unenrolled Activity " + refundResult;
console.log("#️⃣ action done");
const notification = "📧 Activity booked " + businessResult;
console.log("#️⃣ notification sent");
} catch (error) {
console.error("#️⃣ 😵‍💫 error: " + error);
}
return businessResult;
}
// ToDo: confirm activity
// ToDo: cancel activity
}

export class Application {
private service = new EnrollmentService();
public run(): void {
this.service.enrol("Snorkeling on the Red Sea");
this.service.unenroll("Snorkeling on the Red Sea");
}
}

const application = new Application();
application.run();

So you have an algorithm common to several processes, usually involving generic aspects like logging, error handling, etc. The Template Method pattern allows you to define the expected behavior in a base class and the specific conduct in subclasses.

The solution with the Template pattern

// * ✅ Command solution

export interface BusinessProcess {
execute(payload: string): string;
}

export abstract class BusinessTemplate implements BusinessProcess {
public execute(payload: string): string {
this.validation(payload);
try {
// * 😏 hard coded instrumentation steps
console.log("#️⃣ transaction started");
// * 😏 mandatory steps
const paymentResult = this.paymentsTransaction(payload);
const businessResult = this.doBusinessAction(paymentResult);
console.log("#️⃣ action done");
// * 😏 optional step with default implementation if not overridden
this.sendNotification(businessResult);
console.log("#️⃣ notification sent");
return businessResult;
} catch (error) {
// * 😏 hard coded common step
console.error("#️⃣ 😵‍💫 error: " + error);
return "";
}
}
// * 😏 mandatory steps to be implemented by subclasses
protected abstract paymentsTransaction(payload: string): string;
protected abstract doBusinessAction(payload: string): string;
// * 😏 optional step with default implementation if not overridden
protected validation(payload: string): void {
if (payload === "") {
throw new Error("Activity name is required");
}
}
protected sendNotification(payload = ""): void {
console.warn("✅ Done " + payload);
}
}

// * 😏 custom implementation steps while enrollment or cancellation

export class EnrollActivity extends BusinessTemplate {
protected paymentsTransaction(destination: string): string {
return "🤑 Paying Activity to " + destination;
}
protected doBusinessAction(payment: string): string {
return "✍🏼 Booking Activity " + payment;
}
// * 😏 optional step overridden with custom implementation
protected override sendNotification(booking: string): void {
console.warn("📧 Activity booked " + booking);
}
}

export class CancelActivity extends BusinessTemplate {
protected paymentsTransaction(destination: string): string {
return "💸 Refunding Activity " + destination;
}
protected override doBusinessAction(refund: string): string {
return "😭 Cancelling Activity " + refund;
}
// * 😏 optional step (sendNotification) inherited with default implementation
}

// * 😏 creating a new business process is easy while ensures the same steps

export class Client {
// * 😏 you can depend on abstraction not implementation
private enrolling: BusinessProcess = new EnrollActivity();
private cancel: BusinessTemplate = new CancelActivity();
public run(): void {
this.enrolling.execute("Snorkeling on the Red Sea");
this.cancel.execute("Snorkeling on the Red Sea");
}
}

const client = new Client();
client.run();type

This pattern leverages the inheritance mechanism to define the expected behavior in a base class and the specific conduct in subclasses. The base class defines the algorithm, and the subclasses implement the particular behavior while respecting the Liskov Substitution Principle.

Frameworks use this pattern to call hooks at specific points of the object`s life cycle.

🏦 The Memento Pattern.

Stores and restores the state of an object without exposing its internal structure

The problem.

// ! ❌ Bad example not using a memento

// ! 😱 want to save state of an object to undo operations deferred in time
export class Activity {
// ! 😱 private data no serializable
private title: string;
private attendeesRepository: string[] = [];
private places: number = 0;
private reservedPlaces: number = 0;
private minimumAttendees: number = 3;
// ! 😱 redundant or calculated data
private status: "pending" | "confirmed" | "cancelled" = "pending";
public isConfirmed: boolean = false;
public readonly availablePlaces: number = this.places - this.reservedPlaces;

constructor(title: string, places: number) {
this.title = title;
this.places = places;
}

enroll(name: string): void {
if (this.status === "cancelled") throw new Error("Cannot enroll a cancelled activity");
if (this.reservedPlaces >= this.places) {
throw new Error("No more places available on " + this.title);
}
this.attendeesRepository.push(name);
this.reservedPlaces++;
if (this.reservedPlaces >= this.minimumAttendees) {
this.status = "confirmed";
this.isConfirmed = true;
}
}
unenroll(): void {
// ! 😱 unenrolled logic is needed here
if (this.attendeesRepository.length === 0) {
return;
}
this.attendeesRepository.pop();
this.reservedPlaces--;
if (this.reservedPlaces < this.minimumAttendees) {
this.status = "pending";
this.isConfirmed = false;
}
}
cancel(): void {
this.status = "cancelled";
this.isConfirmed = false;
}
}

The Memento pattern is like a state manager that allows you to store in a repository the state of an object for restoring it later. This is useful when you need to undo or redo an operation.

The solution with the Memento pattern.

// * ✅ Memento solution
export class Activity {
private title: string;
private attendeesRepository: string[] = [];
private places: number = 0;
private reservedPlaces: number = 0;
private minimumAttendees: number = 3;
private status: "pending" | "confirmed" | "cancelled" = "pending";
public isConfirmed: boolean = false;
public readonly availablePlaces: number = this.places - this.reservedPlaces;

constructor(title: string, places: number) {
this.title = title;
this.places = places;
}

enroll(name: string): void {
if (this.status === "cancelled") throw new Error("Cannot enroll a cancelled activity");
if (this.reservedPlaces >= this.places) {
throw new Error("No more places available on " + this.title);
}
this.attendeesRepository.push(name);
this.reservedPlaces++;
if (this.reservedPlaces >= this.minimumAttendees) {
this.status = "confirmed";
this.isConfirmed = true;
}
}
unenroll(): void {
if (this.attendeesRepository.length === 0) {
return;
}
this.attendeesRepository.pop();
this.reservedPlaces--;
if (this.reservedPlaces < this.minimumAttendees) {
this.status = "pending";
this.isConfirmed = false;
}
}
cancel(): void {
this.status = "cancelled";
this.isConfirmed = false;
}

takeSnapshot(): ActivityMemento {
// * 😏 similar to a prototype
// * 😏 get private values
const memento: ActivityMemento = {
title: this.title,
attendees: [...this.attendeesRepository],
places: this.places,
status: this.status,
};
return memento;
}
restore(memento: ActivityMemento): void {
// * 😏 similar to builder
// * 😏 set private values
this.title = memento.title;
this.attendeesRepository = memento.attendees;
this.places = memento.places;
this.status = memento.status;
// * 😏 set calculated values
this.reservedPlaces = memento.attendees.length;
this.isConfirmed = this.status != "cancelled" && this.reservedPlaces >= 3;
}
}

// * 😏 public properties for required memento data
type ActivityMemento = {
title: string;
attendees: string[];
places: number;
status: "pending" | "confirmed" | "cancelled";
};

The trick is to do it without exposing the object’s internal structure. This allows us to work with private attributes and restore them in a particular order (like a builder).

🌅 Conclusion

When you must communicate objects at runtime to achieve a goal, you will repeatedly find yourself in certain situations. There are proven solutions to those problems, and they are called behavioral patterns.

In this article, I showed you their implementation in TypeScript. As a cheat sheet, I remind you of the patterns and their use cases.

  • Strategy: Have a class for each business case and let the flow choose one at runtime based on current conditions.
  • Observer: It decouples producers from consumers, changing invocations to event notifications to which they can subscribe.
  • Command: Make a class for each invocation, store the arguments to delay, repeat or trace the executions.
  • Template: Define standard process steps in a base class and customize them in derived clases.
  • Memento: Store the current values of an object to rebuild an instance from them.

Other behavioral patterns you can find are:

  • State: Have a different class for each state of a process. Control transitions and specific behavior.
  • Mediator: Reduce coupling between classes in complex systems by wiring the invocations through a central class.
  • Chain of Responsibility: Allow the creation of classes that can be hooked as steps in a process.
  • Iterator: To traverse a collection without exposing its internal structure.
  • Interpreter: To create a high-level business language.
  • Visitor: To add new behaviors to a class without changing it.

Here you have a visual cheat-sheet of those Behavioral Design Patterns.

I hope you enjoyed it and learned something new. If you have any questions or suggestions, please leave a comment below.

learn, code, enjoy, repeat

Alberto Basalo

Originally published at https://blog.albertobasalo.dev on March 23, 2023.

--

--

Alberto Basalo
Alberto Basalo

Written by Alberto Basalo

Advisor and instructor for developers. Keeping on top of modern technologies while applying proven patterns learned over the last 25 years. Angular, Node, Tests

No responses yet