There are times when you want to return something other than the normal result of a function call. It’s quite often that we see an exception being thrown in these cases.
Exceptions have their place to be used in exceptional situations (like losing network connectivity) but when used in regular code flow, it can cause issues.
I experienced this when I was a few years into my career. There was an existing piece of code that would parse an XML file and perform validations and then parse it if it was successful. It was built entirely around throwing exceptions. But there were issues with this approach:
Readability of the code: Let’s use this simple code for reference within the context of readability.
A caller of your function would not be aware that an error is being thrown from just the contract of the function. This knowledge becomes a contextual load that team members have to learn and remember at all times.
If we had a new type of validation, should we be creating a new type? We would have to maintain a whole hierarchy of exceptions which we would need to finally map back into the final aggregate exception.
You have to always check the error to ensure it’s the one we are expecting or if it’s an unknown one. You would also need to check the type of each expected error type.
Debugging was a nightmare: The entire code was built around throwing exceptions and catching them, then collecting these for every single field and then throwing an aggregated exception. The code was heavily nested. If you ran the debugger, the control would jump all over the place at multiple try catch and re-throws. In the catch block, you now needed to know the exception type and the knowledge that what was thrown was in fact an exception.
Performance: This piece of code which had to fetch and parse an XML file and then save the parsed data would take about 3 mins which was significantly high given the size of the file. When we changed the code to not throw exceptions, it brought it down to less than 10 seconds which was a massive speed boost. Excessive throwing of exceptions for regular code flow has a performance cost.
How can we not throw with some constraints:
More than one return type: A function should be able to return more than one type which should be clear from the contract of the function.
Ensure the caller handles all the return types: The caller should be made aware of the contract of each return type so they can handle it appropriately.
Extensibility: The contract should allow for new types in the future as the need grows to have newer types.
Is Discriminated Unions the solution?
Discriminated unions is an intersection type with one common property across all of them. This common property can be used as a type assert. Take a look at the example below:
The caller is made aware of the fact that it can be a success or an error in line 20. Using the discriminant property, now you are automatically restricted to only those properties that the particular type is allowed by the compiler. This has reduced the contextual overload of remembering which functions throw which kind of error and you are able to use auto-complete.
What about allowing a new return type?
So, we are able to stop using errors but how can we keep this extensible so that we are able to handle new scenarios in the future?
Let’s add a new requirement where the caller needs to be warned if the input was a float. We still want to parse it but with a warning message. Let’s look at the code below.
We added a new type that returns the parsed value and also the warning. We don’t always need to create a new type if there are new requirements. The types could be made more complex and have discriminated unions as a child prop which could further describe different types of data.
Some caveats:
Having too many types: It’s a good idea to narrow it down and not create a new type for every situation; come up with a structure with a few basic types which can be reused by composing across your code. Below is an example of how we can compose types that can be used across the code base.
Now we have some common types which can then be composed to define different higher-level types. We have Failure which in turn has a discriminated union to tell us if it’s an unknown error vs a validation error. We are also able to tell the consumer if the error is intermittent by giving an isRetryable flag. This way, we can prevent coming up with a different type for every function call which could make it painful to move the result across the code.
Guard clause bloat: When you have multiple calls which are in sequence, your code can look like a trailing guard clause after each call to short circuit which adds some bloat. Below is a very simple example that exaggerates the bloat but highlights what can happen.
To deal with this, I created a library typescript-toolbox that can help. If you follow the previous exercise of having a common failure type, we can use a type assertion callback to define when to escape.
Now, each function in the chain only gets called if the previous one was not a failure. This removes the bloat while giving a type-safe mechanism to chain the calls. The final result is an intersection of the escape type and the output of the last function.
Conclusion
We can see that moving to non-throw code makes the code a lot more readable while adding verbosity. With a bit of work, we can make it manageable.