The Big Short-circuit
Error handling in any given programming language is something that influences every program written in that language. Programmers are usually writing the happy path of their business logic and have to grapple with possible failures along the way. When faced with this problem designing my own language, tenecs, I took a look at what’s out there.
To have an example we can compare over different languages, let’s say you have two functions you want to put together. The scenario would be you’re building some service where ads are shown to free tier users but not premium users. You have “getSubscriptionByUserId”, which gives you the type of subscription a user has. You also have “shouldPlayAdvertisement”, which tells you whether to play the advertisement given the subscription type. Now you have to have “shouldPlayAdvertisementToUser”.
Example in javascript:
function getSubscriptionByUserId(userId) {
throw new Error('failed to connect to database');
}
function shouldPlayAdvertisement(subscriptionType) {
return subscriptionType != 'premium';
}
function shouldPlayAdvertisementToUser(userId) {
const subscription = getSubscriptionByUserId(userId);
return shouldPlayAdvertisement(subscription);
}
Since errors are thrown, in the “shouldPlayAdvertisementToUser” function you can only read the happy path. You don’t know which of the functions invoked might fail.
In wanted to take a nearly opposite approach with tenecs. I didn’t want some short-circuiting that’s invisible at any point in the code. Something more similar to golang, which uses early returns.
Example in golang:
func getSubscriptionByUserId(userId string) (string, error) {
return "", errors.New("failed to connect to database")
}
func shouldPlayAdvertisement(subscriptionType string) bool {
return subscriptionType != "premium"
}
func shouldPlayAdvertisementToUser(userId string) (bool, error) {
subscription, err := getSubscriptionByUserId(userId)
if err != nil {
return false, err
}
return shouldPlayAdvertisement(subscription), nil
}
Example in tenecs:
getSubscriptionByUserId := (userId: String): String | Error => {
Error("failed to connect to database")
}
shouldPlayAdvertisement := (subscriptionType: String): Boolean => {
not(subscriptionType->eq("premium"))
}
shouldPlayAdvertisementToUser := (userId: String): Boolean | Error => {
when getSubscriptionByUserId(userId) {
is err: Error => {
err
}
is userId: String => {
shouldPlayAdvertisement(userId)
}
}
}
Now we have something very explicit, but due to the lack of early returns, it came with a cost. Nesting. Code with lots of errors will be super nested. It’s a big loss in readability. Wouldn’t want to be the next iteration of the hadouken meme (source):
This is probably not a problem you’ve ever faced because all major languages have solutions for it. I’ve mentioned throwing and early returns but there’s another solution worth looking into. Languages that have some sort of “Result” type, that encapsulates the possibility of success or failure, have some additional syntax. We usually call this “syntactic sugar”, because it reads nicer, but is redundant syntax, as it doesn’t enable something new. It’s still not exactly the same, as in tenecs you return directly something like “Boolean | Error” instead of a “Result” type, but it might get us closer.
Scala has for comprehensions, probably inspired by Haskell’s similar solution. It doesn’t fully get rid of the nesting, but you can have only one level of nesting even when calling multiple functions that might error.
Example in Scala:
def getSubscriptionByUserId(userId: String): Try[String] = {
Failure(new RuntimeException("failed to connect to database"))
}
def shouldPlayAdvertisement(subscriptionType: String): Boolean = {
subscriptionType != "premium"
}
def shouldPlayAdvertisementToUser(userId: String): Try[Boolean] = {
for {
subscription <- getSubscriptionByUserId(userId)
} yield shouldPlayAdvertisement(subscription)
}
Gleam has a quite clever solution to this problem. You can use their use keyword to turn the rest of your function into a lambda parameter.
Example in Gleam:
fn get_subscription_by_user_id(user_id: String) -> Result(String, String) {
Error("failed to connect to database")
}
fn should_play_advertisement(subscription_type: String) -> Bool {
subscription_type != "premium"
}
fn should_play_advertisement_to_user(user_id: String) -> Result(Bool, String) {
use subscription <- result.map(get_subscription_by_user_id(user_id))
should_play_advertisement(subscription)
}
Rust has a different way of addressing this by using their “?” operator to short-circuit on the error case.
Example in Rust:
fn get_subscription_by_user_id(user_id: &str) -> Result<String, String> {
Err("failed to connect to database".to_string())
}
fn should_play_advertisement(subscription_type: &str) -> bool {
subscription_type != "premium"
}
fn should_play_advertisement_to_user(user_id: &str) -> Result<bool, String> {
let subscription = get_subscription_by_user_id(user_id)?;
Ok(should_play_advertisement(&subscription))
}
So Rust seems to be really close to something I could use. Since I don’t have a Result type and don’t want to constrain the short-circuiting to a specific Error type, I’ll have to make the user annotate either the type to be returned or the type to use. Inspired by Gleam, I’ll handle this on the left-hand side of variable declaration. Let’s start with an example having both.
Example in tenecs:
shouldPlayAdvertisementToUser := (userId: String): Boolean | Error => {
userId: String ? Error = getSubscriptionByUserId(userId)
shouldPlayAdvertisement(userId)
}
Now I can decide to omit one of the types and let the compiler infer it.
Example in tenecs with variable type inferred:
shouldPlayAdvertisementToUser := (userId: String): Boolean | Error => {
userId :? Error = getSubscriptionByUserId(userId)
shouldPlayAdvertisement(userId)
}
Example in tenecs with returned short-circuit type inferred:
shouldPlayAdvertisementToUser := (userId: String): Boolean | Error => {
userId: String ?= getSubscriptionByUserId(userId)
shouldPlayAdvertisement(userId)
}
It definitely doesn’t feel like a silver bullet. It doesn’t allow chaining of expressions. It doesn’t feel familiar to people coming from any language whatsoever. So I’m not sure if I arrived at a jumbled version or a greatest hits remix, but it seems to be very effective at what it’s supposed to do. So far when writing tenecs code I’m trying to reach for it on every opportunity. Time will tell if I keep growing fond of it or learn to dislike it.