DEV Community

Cover image for Do or do not, there is no try
Sultan
Sultan

Posted on • Updated on

Do or do not, there is no try

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!
Enter fullscreen mode Exit fullscreen mode

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'
}
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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),
})
Enter fullscreen mode Exit fullscreen mode

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!')
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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: Ζ’}
Enter fullscreen mode Exit fullscreen mode

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},
})
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
bafxyz profile image
Andrei Bunulu

Nice article