TL;DR - If you have multiple concrete classes that inherit from the same base class, or implement the same interface. You should consider using visitor pattern. It will save you from dozens of if-else block or switch/case and typecasting.
I presented it at SingaporeJS - talk.js - July 2020. See the slide deck:
https://slides.com/trungvose/angular-using-visitor-design-pattern-with-typescript
Visitor is a behavioral design pattern that allows adding new behaviors to existing class hierarchy without altering any existing code.
There weren’t a lot of examples on Visitor pattern because of its popularity compared to the well-known Factory or Command pattern. And with the available example that I could find, it is very conceptual, you wouldn’t be able to imagine how to use it in your real-world use case.
At Zyllem, we are using it extensively on the server-side code. On the client-side, I took me sometimes to have my first visitor running on production. The example below was not that first one though 😁
In my application, I have a map view that displaying a route which contains:
So easily you could see there are three types of markers that I need to display on the maps. Each of them will have the behaviors:
See the gif below from my actual application.
I will walk through how I built it with visitor design pattern. Noted that it is the much simpler version than the actual one. I have removed all the complicated icons with custom HTML real-time communication for the live location and so on. You can view the running example at the end of this post on stackblitz. Or view the completed source code at github.
I am using Google Maps (GM) to display all of these data on a map view. For GM, to display a marker/pin on maps, we could use google.maps.Marker
. To display a route, It has google.maps.Polyline
.
GM will consider all the marker as google.maps.Marker
. But from my point of view I will have one base class the representing a pin on GM. And three concrete classes that corresponded to three types of markers I listed above. Think about a base class name CustomMarker
. And three concrete classes are:
PointMarker
RealTimeLocationMarker
StartLocationMarker
A mentioned above, I have a RouteModel
which include a list of PointModel
, a current live location RealTimeLocationModel
and a start location StartLocationModel
export class RouteModel {
id: string;
points: PointModel[];
startLocation: StartLocationModel;
realTimeLocation: RealtimeLocationModel;
//And many more properties
pathFromStartLocation: any;
}
export class PointModel {
public id: string;
public sequence: number;
public location: LocationApi;
//code removed for brevity
}
export class RealtimeLocationModel {
public geoCoordinate: GeoCoordinateApi;
public capturedTimeStamp: string;
//code removed for brevity
}
export class StartLocationModel {
public location: LocationApi;
//code removed for brevity
}
I use generic to pass the type T
into a property named data
to hold an actual object that I have received from the API. The rest are all the necessary info that GM required to display a marker such as the google.maps.LatLng
. We need the type
property to know which marker is this.
And most importantly for the visitor pattern to work, you have to define an abstract method that takes the base CustomMarkerVisitor
interface as an argument.
export abstract class CustomMarker<T> {
abstract id: string
abstract type: CustomMarkerType
abstract position: google.maps.LatLng
abstract popupContent: string
abstract data: T
constructor(data: T) {
this.data = data
}
/**
* The CustomMarker declares an `accept` method that should take the base
* visitor interface as an argument.
*/
abstract accept(visitor: CustomMarkerVisitor): void
}
export enum CustomMarkerType {
POINT = 'POINT',
START_LOCATION = 'START_LOCATION',
REAL_TIME_LOCATION = 'REAL_TIME_LOCATION',
}
For each concrete class, you will have to implement the accept
method, because it is defined as an abstract method.
export class PointMarker extends CustomMarker<PointModel> {
//...code removed for brevity
constructor(point: PointModel) {
super(point)
}
accept(visitor: CustomMarkerVisitor) {
visitor.visitPointMarker(this)
}
}
export class StartLocationMarker extends CustomMarker<StartLocationModel> {
//...code removed for brevity
constructor(startLocation: StartLocationModel) {
super(startLocation)
}
accept(visitor: CustomMarkerVisitor) {
visitor.visitStartLocation(this)
}
}
export class RealTimeLocationMarker extends CustomMarker<RealtimeLocationModel> {
//...code removed for brevity
constructor(realTimeLocation: RealtimeLocationModel) {
super(realTimeLocation)
}
accept(visitor: CustomMarkerVisitor): void {
visitor.visitRealTimeLocation(this)
}
/**
* Concrete class may have special methods that don't exist in their
* base class or interface. The Visitor is still able to use these methods
* since it's aware of the component's concrete class.
*/
concreteMethodOfRealTimeLocation() {
return 'Real time'
}
}
The CustomMarkerVisitor
interface declares a set of visiting methods that correspond to the number of concrete CustomMarker
classes. The signature of a visiting method allows the visitor to identify the exact class of the component that it’s dealing with.
export interface CustomMarkerVisitor {
visitPointMarker(markerData: PointMarker);
visitStartLocation(markerData: StartLocationMarker);
visitRealTimeLocation(markerData: RealTimeLocationMarker);
}
A concrete CustomMarkerVisitor implement several versions of the same algorithm, which can work with all concrete CustomMarker
classes. Think about it as a behavior that each concrete CustomMarker
need to have such as click, double click, mouse over, mouse out. For each behavior, we will have a corresponding visitor to handle.
For example, I have a specific visitor MarkerMouseClickVisitor
to handle the marker click event.
export class MarkerMouseClickVisitor implements CustomMarkerVisitor {
constructor(private _api: MapApiService) {}
visitPointMarker(marker: PointMarker) {
this.logMessage(marker);
}
visitStartLocation(marker: StartLocationMarker) {
this.logMessage(marker);
}
visitRealTimeLocation(marker: RealTimeLocationMarker) {
//You could call this method too
//marker.concreteMethodOfRealTimeLocation();
this.logMessage(marker);
}
logMessage(marker: CustomMarker<any>){
this._api.sendMessage(`${marker.title} clicked`)
}
}
I used to do it differently with all the switch/case block. See the example code below for the same UI behavior with switch/case approach and visitor pattern with a mouseover behavior. I personally like the visitor better. Because I used to have the switch/case for every single behavior and I don’t know, I just don’t like too many switch/case blocks. Noted that the below implementation was a much simpler version on my real-world application where it involves router and other services as well. Separated it into a visitor helped me to better understand and isolate my code if there is any bug.
addMarkerToMap(markerData: CustomMarker<any>) {
let marker = new google.maps.Marker({
map: this.map,
position: markerData.position,
icon: markerData.icon,
title: markerData.title,
});
google.maps.event.addListener(marker, "mouseover", () => {
this.openInfoWindow(marker, markerData.popupContent);
switch (markerData.type) {
case CustomMarkerType.POINT:
this._api.sendMessage(`${markerData.title} mouse over`);
break;
case CustomMarkerType.START_LOCATION:
this._api.sendMessage(`${markerData.title} mouse over`);
break;
case CustomMarkerType.REAL_TIME_LOCATION:
this._api.sendMessage(`${markerData.title} mouse over`);
break;
default:
break;
}
});
}
addMarkerToMap(markerData: CustomMarker<any>) {
let marker = new google.maps.Marker({
map: this.map,
position: markerData.position,
icon: markerData.icon,
title: markerData.title
});
google.maps.event.addListener(marker, "mouseover", () => {
this.openInfoWindow(marker, markerData.popupContent);
markerData.accept(new MarkerMouseOverVisitor(this._api));
});
this.markers.push(marker);
}
export class MarkerMouseOverVisitor implements CustomMarkerVisitor {
constructor(private _api: MapApiService) {}
visitPointMarker(marker: PointMarker) {
this.logMessage(marker);
}
visitStartLocation(marker: StartLocationMarker) {
this.logMessage(marker);
}
visitRealTimeLocation(marker: RealTimeLocationMarker) {
this.logMessage(marker);
}
logMessage(marker: CustomMarker<any>) {
this._api.sendMessage(`${marker.title} mouse over`);
}
}
You see how we still can access to markerData
variable on the callback of the mouseover despite the addMarkerToMap
has been finished executing. If you have been working with JavaScript long enough, you will know what I am trying to say.
It is JavaScript Closure.
The benefits of visitor pattern, I think:
CustomMarker
, what I need to do is to update the CustomMarkerVisitor
with a new method. The compiler won’t build until I come to every implementation of CustomMarkerVisitor
to implement it properly with the new method. So that I will not afraid of missing behavior.But also, the visitor pattern has some downside as I realize:
But on this use case of the map, I like how visitor pattern has transformed my code.
https://github.com/trungvose/angular-typescript-visitor-design-pattern-with-google-maps-api
I hope it will help you guys get the idea of visitor pattern :) I know I am not a good writer yet, appreciate all your comments and contributions.