Visitor Pattern in TypeScript
Nicholas Ramkissoon
Posted on January 3, 2024
The visitor design pattern allows the addition of new behaviors to a group of classes (or objects) without altering existing code for those classes.
Compared to other patterns, it is less commonly known because of its complexity and the problem it aims to solve.
As a code base grows, the number of object structures tends to increase. Some of these structures can be directly related via some relationship such as inheritance while others can be completely separate. Suppose we wanted to define a new operation for all of these structures. We can individually edit each and add the appropriate method and implementation. But, what if the operation we need does not relate to the existing purpose of the object? As an example that will be expanded on later, what if we wanted to add a logging operation to a Subscription object? If the purpose of the object was to keep track of the state of the subscription, adding a logging method breaks the single responsibility principle.
The visitor pattern helps solves this and becomes especially attractive if we need to add the operation to multiple kinds of objects and/or we know we will need to add more operation in the future.
In this article, I will walk through a problem where the visitor pattern can be applied and I will implement it in TypeScript with plain JavaScript objects. The repository for the example code is here.
Visitor Pattern Implementation Example
Suppose we have a set of types to represent different objects that are part of an online store:
export type Customer = {
id: string;
name: string;
};
export type Subscription = {
id: string;
customerId: string;
productId: string;
startDate: Date;
endDate: Date;
};
export type Product = {
id: string;
name: string;
price: number;
};
export type ShoppingCart = {
id: string;
customerId: string;
productIds: string[];
};
export type Seller = {
id: string;
name: string;
};
If we wanted to create a new Customer, we can write:
import { Customer } from "src/objects.ts"
const newCustomer: Customer = {
id: "123",
name: "Foo Bar"
}
and if we wanted to console log the new customer in JSON format:
console.log(JSON.stringify(newCustomer));
We can do this for every type of object we have, we can even make a print function:
function printJSON(obj: any) {
console.log(JSON.stringify(obj));
}
Life is good...
Uh oh, new ticket just came in, turns out we cannot log Customer and Seller names for security reasons. Also, no Product names since that really isn't relevant in logs. Oh and our PM said the logs are confusing and we need to add the object type to each log to differentiate log lines.
Suddenly, our clever printJSON function is useless because each type of object has different requirements with respect to logging.
We can create new functions for each type:
function printCustomer(customer: Customer) {
// remove name...
}
function printShoppingCart(cart: ShoppingCart) {
// get the products and sum prices..
}
This feels bad. These functions are just floating around and someone else working on the codebase may not even be aware they exist. Whatever, it works for now.
Life is okay...
Uh oh, the business intelligence team has pinged us asking for logs to be sent to their external service. This requirement is absolutely necessary by the way. And, they don't care about security so send over names and all.
Alright, we do as before and add new log functions because we still need our original log functions.
While writing another N functions, you have a moment of senior engineer premonition. This will surely happen again. Someone is going to ask for another logging format, additions, omissions, etc for all these object types. It might be a completely different operation altogether. Or maybe a new type???
Surely someone has faced a similar problem and have came up with a solution... hopefully an abstract solution... a design pattern, maybe?
Enter Visitor Pattern
The visitor pattern will save you, well, make your life easier in the face of ever-evolving requirements. If you want to eliminate new requirements, the design pattern you are looking for is called the resignation letter.
To implement, we need a visitor, a object or class that visits the objects we want to perform an operation on.
import {
Customer,
Product,
Seller,
ShoppingCart,
Subscription,
} from "./objects";
// any visitor must implement this interface
export interface Visitor<R> {
visitCustomer(customer: Customer): R;
visitSubscription(subscription: Subscription): R;
visitProduct(product: Product): R;
visitShoppingCart(shoppingCart: ShoppingCart): R;
visitSeller(seller: Seller): R;
}
We from the methods on the interface that the Visitor has a specific visit method for each of the object types we want to perform an operation on. We also use the generic R to allow for a generic return type for cases where we want an operation to have a return value.
Before we can create a concrete visitor, we need to make some changes to our object types:
import { Visitor } from "./visitor";
export type Visitable = {
accept<R>(visitor: Visitor<R>): R;
};
export type Customer = {
id: string;
name: string;
} & Visitable;
export type Subscription = {
id: string;
customerId: string;
productId: string;
startDate: Date;
endDate: Date;
} & Visitable;
export type Product = {
id: string;
name: string;
price: number;
} & Visitable;
export type ShoppingCart = {
id: string;
customerId: string;
productIds: string[];
} & Visitable;
export type Seller = {
id: string;
name: string;
} & Visitable;
export function createCustomer(opts: { id: string; name: string }): Customer {
return {
...opts,
accept<R>(visitor: Visitor<R>): R {
return visitor.visitCustomer(this);
},
};
}
export function createSubscription(opts: {
id: string;
customerId: string;
productId: string;
startDate: Date;
endDate: Date;
}): Subscription {
return {
...opts,
accept<R>(visitor: Visitor<R>): R {
return visitor.visitSubscription(this);
},
};
}
export function createProduct(opts: {
id: string;
name: string;
price: number;
}): Product {
return {
...opts,
accept<R>(visitor: Visitor<R>): R {
return visitor.visitProduct(this);
},
};
}
export function createShoppingCart(opts: {
id: string;
customerId: string;
productIds: string[];
}): ShoppingCart {
return {
...opts,
accept<R>(visitor: Visitor<R>): R {
return visitor.visitShoppingCart(this);
},
};
}
export function createSeller(opts: { id: string; name: string }): Seller {
return {
...opts,
accept<R>(visitor: Visitor<R>): R {
return visitor.visitSeller(this);
},
};
}
In order for an object type to be visited, it has to be able to accept a visitor object. We union our object types with the Visitable type:
export type Visitable = {
accept<R>(visitor: Visitor<R>): R;
};
Then, so we don't have to implement accept every time we create a new object, we create helper methods for creating new objects for each type.
For example:
export function createCustomer(opts: { id: string; name: string }): Customer {
return {
...opts,
accept<R>(visitor: Visitor<R>): R {
return visitor.visitCustomer(this);
},
};
}
Notice the implementation of accept, we call the visitor's visitCustomer method that was defined in the Visitor interface.
If we wanted to add a new object type, follow these steps:
- define the type and make it Visitable
- create the constructor method and implement accept
- add the visit method for the new type to the Visitor interface
The type system will guide you through adding the new object type, easy-peasy.
Visitor #1: JSON logger
It is now time to create our first visitor: The JSON logger. The set up we did before has done most of the work, look how simple the implementation is for the logger:
import { Visitor } from "./visitor";
export const jsonLogger: Visitor<void> = {
visitCustomer(customer) {
console.log(`Customer: ${JSON.stringify({ id: customer.id })}`);
},
visitSubscription(subscription) {
console.log(`Subscription: ${JSON.stringify(subscription)}`);
},
visitProduct(product) {
const { id, name, price } = product;
console.log(`Product: ${JSON.stringify({ id, price })}`);
},
visitShoppingCart(shoppingCart) {
console.log(`ShoppingCart: ${JSON.stringify(shoppingCart)}`);
},
visitSeller(seller) {
console.log(`Seller: ${JSON.stringify({ id: seller.id })}`);
},
};
So straightforward I do not have to explain much. The beauty of this pattern is that the logging logic is separate from the object types and it is grouped together. Plus, we can implement custom logic for each type--just as our requirements defined. Now, if we need to make a change to the any of the logging methods we know exactly what to change and where without touching the object type itself.
To use the visitor:
import { jsonLogger } from "./json-logger";
import { createCustomer } from "./objects";
const customer1 = createCustomer({
id: "1",
name: "John Doe",
});
customer1.accept(jsonLogger);
Usage is also straightforward, create the object and pass in the desired visitor object to the accept method.
We can go one step further though. If we were writing a library for other developers, using the accept method and passing in a Visitor is not the best API because the word 'accept' does not say much and passing in the object that does the work does not make sense semantically (the object doing the thing should be the one calling a function, usually).
We want usage to look like this:
jsonLogger.log(customer1);
We do this by turning JSON logger into a class that implements the Visitor interface. Then we implement a log method:
import type {
Customer,
Subscription,
Product,
ShoppingCart,
Seller,
Visitable,
} from "./objects";
import { Visitor } from "./visitor";
class JsonLogger implements Visitor<void> {
visitCustomer(customer: Customer) {
console.log(`Customer: ${JSON.stringify({ id: customer.id })}`);
}
visitSubscription(subscription: Subscription) {
console.log(`Subscription: ${JSON.stringify(subscription)}`);
}
visitProduct(product: Product) {
const { id, price } = product;
console.log(`Product: ${JSON.stringify({ id, price })}`);
}
visitShoppingCart(shoppingCart: ShoppingCart) {
console.log(`ShoppingCart: ${JSON.stringify(shoppingCart)}`);
}
visitSeller(seller: Seller) {
console.log(`Seller: ${JSON.stringify({ id: seller.id })}`);
}
log(visitable: Visitable) {
visitable.accept(this);
}
}
export const jsonLogger = new JsonLogger();
The log method takes in any Visitable and calls its accept method which we know redirects back to the calling Visitor object to select the correct operation.
Visitor #2: Log to an external service
Remember the BI team needed logs sent to their external service? All we need to do now is create a new visitor:
import type {
Customer,
Subscription,
Product,
ShoppingCart,
Seller,
Visitable,
} from "./objects";
import { Visitor } from "./visitor";
const externalServiceClient = {
async send(data: string) {
// send data to external service
return 200; // OK
},
};
const stringify = (data: any) => JSON.stringify(data);
class ExternalLogger implements Visitor<Promise<number>> {
async visitCustomer(customer: Customer) {
return externalServiceClient.send(stringify(customer));
}
async visitSubscription(subscription: Subscription) {
return externalServiceClient.send(stringify(subscription));
}
async visitProduct(product: Product) {
return externalServiceClient.send(stringify(product));
}
async visitShoppingCart(shoppingCart: ShoppingCart) {
return externalServiceClient.send(stringify(shoppingCart));
}
async visitSeller(seller: Seller) {
return externalServiceClient.send(stringify(seller));
}
async log(visitable: Visitable) {
return visitable.accept(this);
}
}
export const externalLogger = new ExternalLogger();
And we use it like so:
const printExternalLog = async () => console.log(await externalLogger.log(customer1));
printExternalLog(); // prints 200
It's almost too easy to create a new visitor for a new operation we need. Let's look at another one.
Visitor #3: Validate object fields
In a typical application, we would save our objects in a database. We should not assume all fields for a specific object are valid before saving to the database, especially if fields can be changed by the user.
Let's implement a new visitor that validates the fields of each of our object types. Ideally, we can use something like zod to create a powerful validator, but for simplicity we'll implement checks manually.
import type {
Customer,
Subscription,
Product,
ShoppingCart,
Seller,
Visitable,
} from "./objects";
import { Visitor } from "./visitor";
class Validator implements Visitor<void> {
visitCustomer(customer: Customer) {
if (customer.name.length === 0) {
throw new Error("Customer name is empty");
}
}
visitSubscription(subscription: Subscription) {
if (subscription.startDate > subscription.endDate) {
throw new Error("Subscription start date is after end date");
}
}
visitProduct(product: Product) {
if (product.price < 0) {
throw new Error("Product price is negative");
}
}
visitShoppingCart(shoppingCart: ShoppingCart) {
if (!shoppingCart.customerId) {
throw new Error("Shopping cart has no customer");
}
}
visitSeller(seller: Seller) {
if (seller.name.length === 0) {
throw new Error("Seller name is empty");
}
}
validate(visitable: Visitable) {
visitable.accept(this);
}
}
export const validator = new Validator();
The Validator visitor highlights the ability for the visitor pattern to allow custom logic depending on the object type while still co-locating related operations.
const customer2 = createCustomer({
id: "2",
name: "",
});
try {
validator.validate(customer1);
console.log("Customer 1 is valid");
validator.validate(customer2);
} catch (e) {
console.error(e);
}
Conclusion
We implemented a few Visitors in TypeScript to highlight the pattern's flexibility. Although many applications may never need to use this design pattern, it is helpful to know it exists and understand how it works in case you run into it.
Posted on January 3, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.