The main building blocks of functional programming are pure functions. Side effects are their worst enemies because they make functions impure. For instance, JSON.parse
meets nearly all the criteria for a pure function: it always produces the same output for the same input, and it does not depend on the global context or attempt to change it. However, it still harbors a side effect. When an invalid JSON is applied, the function not only fails to return a result but also breaks the application.
const bob = JSON.parse('{"name": "Bob"}') // -> Bob
const jackson = JSON.parse('Beat me, hate me') // -> π₯Error!
This behavior is by design, and to handle this issue, we need to wrap the function call in a try/catch
block.
let jackson
try {
jackson = JSON.parse('You can never break me')
}
catch(error) {
jackson = 'n/a'
}
We had to slightly increase the code and use a mutable variable. Using let
is not that desirable, as this would require a more careful analysis of the code to track all the places where the variable can be changed, especially when dealing with larger codebases.
I would like to find a more elegant solution to this problem. As an idea, we can try adding an onError
method to achieve a one-line solution:
const jackson = JSON.parse('Beat me, hate me').onError(() => 'n/a')
It looks nice, but extending objects or functions through prototypes is playing with fire. Moreover, it's not universally applicable. Every time we need to handle an exception, we first have to modify the original function and then call it. Doesn't sound that great, does it? But what if we create a wrapper function that temporarily extends any function with an onError
method, without modifying it? In other words, the wrapper function will loan its methods to the wrapped functions, and once the execution is complete, the methods will be returned back to the owner. But don't worry, this will be an interest-free loan, so you wonβt have to pay a dime!
const jackson = trap(JSON.parse('Beat me...')).onError(() => 'n/a')
To make usage even more convenient, we can add pipeline support for function composition, as follows:
const userName = trap('You can never break me')
.then(JSON.parse)
.then(user => user.name)
.onError(() => 'n/a')
.finally()
It closely mirrors Promise
, with the only difference being the onError
and catch
methods. Essentially, we can use Promise
directly without any additional wrapping functions.
const parseName = str => Promise.resolve(str)
.then(JSON.parse)
.then(user => user.name)
.then(str => str.trim())
.then(str => str.toUpperCase())
.catch(() => 'n/a')
.finally()
const bob = await parseName('{"name": " Bob "}') // -> 'BOB'
const sam = await parseName('{"nick": " Sam "}') // -> 'n/a'
const one = await parseName('{"name": 1}') // -> 'n/a'
const jackson = await parseName('You can never break me') // 'n/a'
The sequence of then
and catch
functions provides clear control flow and error handling. However, there is a small problem: the parseName
function is now asynchronous, so the value assignment won't happen immediately after the function execution, but rather on the next event loop tick. This is not what we want. Luckily, we can create a function with the same interfaces as Promise
, but it will work synchronously.
const flow = x => ({
then: f => trapError(() => f(x)),
catch: () => flow(x),
finally: (f = x => x) => f(x),
})
const fail = x => ({
then: f => fail(x),
catch: f => trapError(() => f(x)),
finally: () => {throw x},
})
function trapError(f) {
try {
return flow(f())
}
catch (error) {
return fail(error)
}
}
Below is an example of usage. In order to break chaining and get the value, we should call the finally
method.
const findUser = id => flow(localStorage.getItem('users'))
.then(JSON.parse)
.then(users => users.find(user => user.id === id))
.then(user => user.name)
.catch(() => 'n/a')
.then(str => str.toUpperCase())
.finally() // π stop chaining and get value
const bob = findUser(12)
Interesting fact: if you call the flow
function with await
, it may execute without invoking finally
, even if the function is synchronous.
const ten = flow(7).then(x => x + 3).finally() // -> 10
const two = await flow(1).then(x => x + 1) // -> 2
Our function looks and behaves like an asynchronous function, but it does not actually support asynchronous code. This can be misleading. To avoid confusion, I suggest adding support for asynchronicity in the following way:
const isPromise = x => x instanceof Promise
const future = p => ({
then: f => future(p.then(f)),
catch: f => future(p.catch(f)),
finally: (f = x => x) => p.then(f),
})
const flow = x => isPromise(x) ? future(x) : ({
then: f => trapError(() => f(x)),
catch: () => flow(x),
finally: (f = x => x) => f(x),
})
Now, we can seamlessly mix asynchronous functions in our pipeline without concerns about distinguishing between synchronous and asynchronous code.
const refreshToken = salt => flow(localStorage.getItem('app'))
.then(JSON.parse)
.then(data => data.session.refreshToken)
.then(saltedToken => atob(saltedToken).replace(salt, ''))
.then(token => fetch(`/api/refresh-token/${token}`)) // π now it is async fn
.then(res => res.json())
.then(json => json.token)
.catch(() => window.location.href = '/login')
const token = await refreshToken('Never store token in the local storage!')
Functors and Monads
Without realizing it, we have gradually started using functors in our examples. It might come as a surprise, but the functions future
, flow
, and fail
are actually functors. A functor acts like a container that holds a value, and we can access and potentially modify this value using the function provided to the then/catch
methods. The functor flow
recursively calls itself with a new value, allowing us to create endless chains of then/catch
and build function compositions. Each then/catch
is enclosed within a try/catch
, ensuring safe function execution and managing two pathways: a successful path (then) and an error-handling path (catch). To retrieve the final value, we need to invoke the finally
function, which ends the then/catch
chain and delivers the result. An interesting feature of the finally
function is that it can take another function for one last transformation before concluding.
const greet = flow('bob')
.then(str => str.toUpperCase())
.then(str => 'Hello '.concat(str))
.finally(str => str.concat('!'))
console.log(greet) // -> Hello BOB!
Seems like everything is great, but there is still one thing. If we pass another flow into our pipeline, we will get a functor instead of a result value.
// welcome without finally
const welcome = name => flow(name)
.then(str => str.toUpperCase())
.then(str => 'Hello '.concat(str))
.catch(() => 'Oops')
flow('{"name": "Bob"}')
.then(JSON.parse)
.then(user => user.name)
.then(welcome) // π nesting another flow will break π
.finally(console.log) // -> {then: Ζ, catch: Ζ, finally: Ζ}
To unfold nested functors, the following changes need to be made:
const isFunctor = x => x?.finally instanceof Function
const flow = x => {
if (isPromise(x)) return future(x)
if (isFunctor(x)) return x
return {
then: f => trapError(() => f(x)),
catch: () => flow(x),
finally: (f = x => x) => f(x),
}
}
const fail = x => ({
then: (f, r) => r?.(x) ?? fail(x), // π handle fail in promise
catch: f => trapError(() => f(x)),
finally: () => {throw x},
})
These changes make flow
a monad. Although it may not be the ideal monad, it possesses some key properties of a monad. This allows us to nest other flows or functors within it.
flow('{"name": "Bob"}')
.then(JSON.parse)
.then(user => user.name)
.catch(() => 'guest')
.then(welcome) // π nesting another flow
.then(str => str.concat('!'))
.finally(console.log) // -> Hello BOB!
In practice, you most likely don't need to use monads in their pure form. Instead, you will often use them inside functions that return values from the monad. However, there are cases when nesting functors/monads inside then/catch
methods is reasonable. With the fail
functor, we can interrupt the execution flow without throwing an exception. It is worth noting that throwing an exception can be a more costly operation compared to a regular function call.
const findUser = id => flow(localStorage.getItem('users'))
.then(JSON.parse)
.then(users => users.find(user => user.id === id))
.then(user => user || fail('User not found')) // π jump to the next catch on null
.then(user => user.name)
.catch(() => 'n/a')
.then(str => str.toUpperCase())
.finally()
const bob = findUser(12)
Another example is the halt
functor, which can be used to skip all chains with a given value. This provides an easy way to end the entire pipeline.
const halt = x => ({
then: () => halt(x),
catch: () => halt(x),
finally: (f = x => x) => f(x),
})
const findUser = id => flow(localStorage.getItem('users'))
.then(JSON.parse)
.then(users => users.find(user => user.id === id))
.then(user => user || halt('N/A')) // π quit with value
.then(user => user.name)
.then(str => str.toUpperCase())
.finally()
Conclusion
Is this solution a complete replacement for try/catch
? I would say that, no, it is not a replacement, but rather a powerful addition for managing code execution and error handling. This approach is convenient in cases where a function can continue its execution despite an error occurring. Additionally, this design pattern can be applied not only for error catching but also for other purposes. Here are some possible applications:
const Component = select(role)
.when('admin', AdminView)
.when('editor', EditorView)
.when('user', UserView)
.otherwise(GuestView)
const selectGrade = mark => select(mark)
.when(val => val > 90, 'A'),
.when(between(80, 89), 'B'),
.when(between(70, 79), 'C'),
.when(between(50, 69), 'D'),
.otherwise('F')
const grade = selectGrade(78) // -> C
In this article, I intentionally did not follow the conventions and laws of monads. My objective was to introduce readers to an alternative approach to development while highlighting the beauty of monads, which is often hidden beneath academic terminology and excessive boilerplate code. Naturally, it is not possible to cover all possible aspects of this approach in a single article, but I hope that the information shared here will serve as a starting point for you to explore the world of monads.
P.S. You can find the online demo here πΊ.
Top comments (1)
Nice article