What is railway oriented programming?
I recently stumbled upon Scott Wlaschin’s talk on railway oriented programming where he talked about an epic new way of handling errors using the functional approach. In this lecture, he uses a railway track as an analogy to give developers a better understanding of the pattern. The philosophy itself isn’t directly related to programming, but it can help you improve your codebase.
Railway oriented programming is a functional approach to the execution of functions sequentially. I will be using error handling as the case study here. Apart from error handling, there are various other applications for the railway oriented pattern in general.
The main point is that your function can only return either a success or a failure. Failure should be handled using the throw statement so as to throw an exception, while success is what leads to another function, which can be of any type.
This style of error handling uses monadic behavior — a substitute way of handling errors. One thing I really like about this style is the elegance and readability it provides to your codebase.
It is nearly impossible these days to have a program that doesn’t need to be handling errors. Even the simplest of programs need error handling, from validating users’ input details, network issues, handling errors during database access, and so many related situations that can crop up while coding.
Back to what railway oriented programming really is. Below is a visual representation of what this looks like:
In a simpler form, every method or function either yields a success or an error (the word failure sounds cooler to me, though.)
In a real-world application, we might also want to move from error to success. This is called self-healing in Node.js, for example.
From my understanding, I have found various applications for the railway oriented pattern that go beyond error handling. One is the control flow. This idea incorporates interactivity into your application, thereby providing conditionals.
Now, let’s get deeper into the specifics of this pattern. Ultimately, railway oriented programming boils down to two options: the happy path and the unhappy path.
The happy path
Let’s imagine we want to read the content of a file and send it as an email to a customer. In order to successfully complete this task, the customer’s email must be valid, and it has to have a complete name.
# Happy Path
> read file
> get email address
> get firstname and lastname
> send email
Where:
const sendWayBillMail = async () => {
const data = await fs.readFile("emailContent.txt", "binary")
const { emailAddress, firstName, lastName } = await User.findById(userId)
sendMail(emailAddress, firstName, lastName, data)
return "Done"
}
There you have it. This makes us happy. This looks ideal, but in real life it isn’t perfect. What if we don’t get the specific outcome we want? What if the file is invalid? What if our firstName wasn’t saved? What if? What if? Now, we’re getting pretty unhappy here. There’s plenty of things that could potentially go wrong.
An example of an unhappy path would be this:
const sendWayBillMail = async () => {
const data = await fs.readFile("emailContent.txt", "binary")
if (!data) {
return "Empty content or invalid!"
}
const { emailAddress, firstName, lastName } = await User.findById(userId)
if (!emailAddress) {
return "Email address not found!"
}
const isValidated = await validateEmail(emailAddress)
if (!isValidated) {
return "Email address not valid!"
}
if (!lastName) {
return "Last name not found!"
}
if (!firstName) {
return "First name not found!"
}
sendMail(emailAddress, firstName, lastName, data)
return "Done"
}
The unhappy path grows faster than unexpected. First, you think the file read could be empty or invalid. Then, you see that the isValidated response might be a fail. Then you remember you need to check for a null email. Then you realize the lastName must not be there, and so on.
Finding the unhappy paths is always quite a challenge, which is extremely bad for building software. You might wake up to a series of bug reports in your inbox from your users. The best thing to do is to always put your feet in your users’ shoes.
Our savior
The main goal of railway oriented programming is to ensure every function or method should and must always return a success or a failure. Think of it like a typical railway track — it either goes left or right.
The main idea is to tackle the happy path as if it’s the main path — it should be where you’re normally heading. In the image below, it’s the green track. If there is a failure, we move to the error track. In our case, it’s the red track.
We stay on this track until the error is dealt with using recovery, which shifts the flow back to the main track.
Through this method, we push error handling to where it belongs and control the flow of exceptions while creating a pipeline. Everything moves on the green track if there is a happy outcome, and if we get an unhappy outcome, it switches to the red track at that instant and flows to the end.
So, how do we apply this to our current code? The main idea of ROP, again, is to create several functions that can switch between the two tracks while still following the pipeline.
This ‘switches’ idea is what brings about the two track system:
In our code, we already have the validateEmail function, so we just apply the switch to it by adding if/else. If/else will handle the success and failure functions.
const validateEmail = async email => {
if (email.includes("@")) Success
else Failure
}
However, the above code syntax is not correct. The way we illustrate the success and the failure is through the green and red track.
This outlook requires us to implement every task as a function, which yields no interfaces except for one. This provides much better code maintainability and control over the application flow.
const sendWayBillMail = async file => {
const data = await readFile(file)
const { emailAddress, firstName, lastName } = await User.findById(userId)
const response = await checkForNull(emailAddress, firstName, lastName)
const isValidated = await validateEmail(response.emailAddress)
sendMail(response.emailAddress, response.firstName, response.lastName, data)
return "Done"
}
In each of these functions, we then handle errors as they should be handled, which is through the two track unit. The above code can still be refactored to achieve simplicity and reliability.
The advantages of railway oriented programming
It’s important to keep in mind that the railway pattern is an orientation or design style. It’s less about the code itself, and it’s more about applying the pattern to your code to improve efficiency and reliability.
In general, patterns have advantages as well as disadvantages. That being said, you should consider railway oriented programming as a choice you make for your code rather than a rule you always have to follow when building an application.
Deciding how to carry out error handling is a matter of perspective, which is why we have the railway oriented pattern.
If you are going to choose to utilize railway oriented programming, here are some of the benefits you’ll see:
- Authenticity: Each function will always yield a failure or a success.
- Clarity: It’s very easy to apply, and it’s also quite lucid. It does not require you to implement any special features.
- Compatibility: Each function (or task) that is connected by composition is compatible. That means each function is a black box and does not disturb the next function during maintainability by the developer.
The above advantages will ultimately improve your codebase. It comes with test-driven development and does not affect the performance of your application.
Conclusion
This article helps you wrap your head around the idea of the “parallel error handling” technique. You can get more information about this method by checking out Scott Wlaschin’s full lecture on the pattern.
Railway oriented programming gives us a sense of our validation as an independent function, creating two results for our pipeline. Now you can apply this method to handle the happy and unhappy paths in your code in a clean and functional way.