02. Router Pattern
How do you tame a group of if-else or switch statements that grows with every feature request? Let’s continue obliterating if-else statements on today’s episode of TL;DR, the JavaScript codecast series that teaches working web developers to craft exceptional software in 5 minutes a week.
Transcript
Last episode we covered nested ternaries. Nested ternaries are a great replacement for if-else statements when we need the power of a conditional, but can afford to replace statements with expressions. But sometimes the number of cases just gets longer and longer.
Even if you extracted each case to a separate function, the function wrapping around the if-else or switch statement will continue to grow unbounded.
Especially in codebases that change hands often, this promotes a sloppy, inconsistent boundary between the individual cases and the mapping logic that decides which case to run.
Today we’re refactoring some code for a chatbot that helps outdoor enthusiasts find great trails to hike.
console.log( responder('list hikes') );
// => Lost Lake
// => Canyon Creek Meadows
console.log( responder('recommend hike') );
// => I recommend Mirror Lake.
console.log( responder('add hike Mirror Lake') );
// => Added Mirror Lake!
console.log( responder('where is Mirror Lake') );
// => Sorry, I don’t understand that.
So far this chatbot can respond to a few basic commands, like “list hikes”, “recommend hike”, and “add hike”. If you ask the chatbot something it doesn’t understand — like “where is Mirror Lake” — it responds with a fallback message.
At the moment, all of this logic lives in the responder function. Our chatbot currently has 4 behaviors, so there are 3 if-else cases and one fallback return statement.
let hikes = [
'Lost Lake',
'Canyon Creek Meadows',
];
let randomHike = () =>
hikes[Math.floor(Math.random() * hikes.length)];
let responder = (message) => {
if (message === 'list hikes') {
return hikes.join('\n');
} else if (message === 'recommend hike') {
return `I recommend ${randomHike()}`;
} else if (message.startsWith('add hike')) {
let hike = message.slice(9);
hikes.push(hike);
return `Added ${hike}!`;
}
return "Sorry, I don't understand that.";
};
This code is short right now, but that’s because our chatbot only supports 3 commands so far. It will need to understand many more commands, and each new command will add another if-else case.
Ballooning if-else or switch statements are a code smell that suggest the responder function might have too many responsibilities.
So how could we eliminate these cascading if-else statements before they grow to hundreds of cases?
Enter the Router. The Router is a design pattern that helps us turn a giant if-else or switch statement inside out by decoupling the responsibility of routing logic from the business logic of the individual cases.
The Router pattern is particularly nice because we can follow a step-by-step procedure to refactor the code, and at each step the code should still run.
The first step is to extract each case into a separate function and list them in a data structure, like a plain ol’ JavaScript object. Let’s move the code for the 3 chatbot commands into an object called “responses”, using the command as the key.
let responses = {
'list hikes': () =>
hikes.join('\n'),
'recommend hike': () =>
`I recommend ${randomHike()}`,
'add hike': (message) => {
let hike = message.slice(9);
hikes.push(hike);
return `Added ${hike}!`;
},
};
Now that we’ve moved each command into responses, we can replace the cases by looking up the appropriate response function and invoking it. At this point, our code should still work exactly as before.
let responder = (message) => {
if (message === 'list hikes') {
return responses['list hikes']();
} else if (message === 'recommend hike') {
return responses['recommend hike']();
} else if (message.startsWith('add hike')) {
return responses['add hike'](message);
}
return "Sorry, I don't understand that.";
};
We’ve finished the first step — it’s usually pretty mechanical, but it often spawns other refactors as you discover subtle side effects and hidden dependencies that need to be passed as an argument. For example, we quickly realized that the “add hike” command needs the rest of the chat message so it can extract the name of the hike.
Now for step 2: let’s collapse the cascading if-else statements. Since each response is listed with its corresponding command in the responses object, we can use the message to directly look up the appropriate response function.
let responder = (message) => {
let response = responses[message];
if (response) {
return response(message);
}
return "Sorry, I don't understand that.";
};
If a matching response function is found, we’ll invoke it. Also, since one of our chatbot commands needs the message, we’ll pass it as an argument. You’ll need to find a parameter signature that works for any of your response functions, so this may take some additional refactoring. But it’s okay if a response function ignores those arguments, as the “list hikes” and “recommend hike” commands do.
Nice, we collapsed a 3 case if-else statement into one! In step 3 we’ll eliminate the if-else statement altogether by extracting the fallback behavior into a function of its own. If no response function matched, we’ll use the double pipe operator to insert the fallback response. Now that we know the response variable will always contain a function, we can invoke it unconditionally.
let fallback = () =>
"Sorry, I don't understand that.";
let responder = (message) => {
let response = responses[message] || fallback;
return response(message);
};
And that’s it! The Router pattern helped us turn an if-else statement with several cases inside out. And now the responder function, which was destined to grow without bound, is a simple shell that just dispatches the message to the appropriate response function. In backend terminology, we call the responder function a “router,” and the commands are called “routes.”
Unfortunately, we broke the “add hike” command that expects the message to include the name of the hike after the command, so our simple property lookup isn’t flexible enough.
To fix this, we’ll convert responses to a list and use the find
Array method to see which command the message starts with.
let responder = (message) => {
let [command, response] = Object.entries(responses)
.find(([command, response]) =>
message.startsWith(command)
);
return response(message);
};
Now that we’ve switched to startsWith
, we can move the fallback code to the responses object, and use an empty string as the key! We just need to make sure it comes last. Now we’ve eliminated conditionals from the responder function entirely!
let responses = {
'list hikes': ... ,
'recommend hike': ... ,
'add hike': ... ,
'': () =>
"Sorry, I don't understand that."
};
See how control flow got replaced by a data structure? That’s a recurring theme in software design: many problems that are traditionally solved with algorithmic code can be described much more elegantly with a data structure, which is easier to debug, extend and reason about.
In the Router pattern, the mapping data structure doesn’t even have to be an object. We could turn the responses object into an array of objects, with one object per command!
let responses = [
{
command: 'list hikes',
response: () => hikes.join('\n')
},
{
command: 'recommend hike',
response: () => `I recommend ${randomHike()}`
},
{
command: 'add hike',
response: (message) => {
let hike = message.slice(9);
hikes.push(hike);
return `Added ${hike}!`;
}
},
{
command: '',
response: () =>
"Sorry, I don't understand that."
}
];
This format gives us flexibility: to define more complex commands, we can easily switch from strings to regular expressions, and even define capture groups for the response function to receive as an argument!
let responses = [
{
command: /^list hikes$/,
response: () => hikes.join('\n')
},
{
command: /^recommend hike$/,
response: () => `I recommend ${randomHike()}`
},
{
command: /^add hike (.+)$/,
response: ([hike]) => {
hikes.push(hike);
return `Added ${hike}!`;
}
},
{
command: /^(.*)$/,
response: ([message]) =>
`Sorry, I don't understand "${message}".`
}
];
let responder = (message) => {
let { command, response } = responses
.find(({ command, response }) =>
command.test(message)
);
return response(
command.exec(message).slice(1)
);
};
Not only did that simplify the code for “add hike”, but it provides new team members with a template for adding new commands. It’s pretty straightforward to add “where is” by using “add hike” as a starting point.
let responses = [
...
{
command: /^where is (.+)$/,
response: ([hike]) =>
`${hike}? Do I look like a GPS receiver?`
},
...
];
The Router pattern helps us discover common needs across if-else cases and provide a flexible interface to DRY them up. Because the routing logic is backed by a data structure, we can do things that were previously impossible with hard-wired if-else or switch statements, like dynamically enabling particular commands at runtime.
And with each case extracted into a function, we can unit test each response without going through the routing logic first!
The Router pattern helps solve the same problems in Functional Programming that polymorphism does in Object Oriented Programming. And it pops up everywhere: in React you might use this pattern to select which component to render, on the backend you could decide which handler function to invoke for a webhook, in a Redux reducer you can delegate state updates to smaller reducers, and of course on the backend you can define routes for a particular URL.
Today, scan through your codebase for switch and if-else statements that tend to grow with each feature request, and use the Router pattern to turn it inside out.
That’s it for today. Want to keep leveling up your craft? Don’t forget to subscribe to the channel for more rapid codecasts on design patterns, refactoring and development approaches.