Ian Byrd


Pipeline-driven error handling

TL;DR:


I’ve started designing a shiny new programming language recently. Turns out, error handling is a crucial of the language design, so I had decided to talk about it a little bit. As you probably know, different languages incorporate different error handling concepts: Java sticks to exceptions, Go prefers to pull C-style errors all the way through the stack, Haskell invents a couple of monads. Tbh, I’m not quite sure what the monad is, but you know, it must be a beast! You see, error handling is always a huge trade-off. Java exceptions let you avoid boilerplate, yet dramatically decrease the code transparency. Go errors are known of causing the dead simple boilerplate code. I wish I could tell you about monads, but since I’m not sure what the monad is, I can’t.

While designing the error handling system for my brand new shiny programming language, I wanted to take best from all the concepts I’ve ever had business with. These include Java exceptions, Go errors, Swift optionals/throws and the Either monad (hope I got this one right). I’m not a huge fan of exceptions, since they tend to bloat error handling logic a lot. That said, I’m not into the Go way as well: pulling the error objects all the way through call stack and doing regular if err != nil checks over and over again sounds like a huge workaround. A workaround I can live with, but you know, that’s more like living with an alcoholic. I also can’t really tell if there is a room and sense for Maybe/Either in non-functional programming language, so I won’t focus on these in terms of this article. Let’s start!

Optionals (optional values)

Optional variable is a popular concept in programming, which basically lets you handle the absence of a value. That said, why’d you want to have an absent value? You wouldn’t, but you inevitably will—because of errors. That said, it makes sense to get an error itself as well. The optional type I came up for my toy language may be either valid or invalid. An optional with error part refering to nil is considered valid. Otherwise, it’s not. Disclaimer: I really like the syntax of optionals in Swift, so I could’ve stolen some of it.

func sqrt(x int) float? {
	if x < 0 {
		return errors.New("there's no square root of a negative number")
	}

	// calculate the square root
	return 420.42
}

var root? = sqrt(-10)

match root {
case float:
	// ...
case error:
	// ...
}

As you could’ve noticed, variable? is the syntax for an optional variable and pattern matching is the way to get the error part out of it. It distorts the way you treat optionals in Swift, so Swift guys might get confused. Oh, a couple of words about the actual errors. Error in Aid is exactly the same to one in Go, it’s a short flexible single-method interface.

Pipes, pipes everywhere

The error handling I came up with is tied with another magnificent concept: the pipeline. In Unix, pipeline is a set of processes chained by their standard streams, so that the output of each process (stdout) feeds directly as input (stdin) to the next one. Fans of the command-line shells use these all the time:

// Get the last 3 lines of the file and replace all the apples with oranges:
$ cat fruits.txt | tail -3 | sed -e "s/apples/oranges/g" > new_fruits.txt

As you can see, pipelines are incredibly concise and meaningful. And what makes them even cooler is that they perform in parallel well! The following command won’t wait for a preceding one to finish. Instead, it’s been started simultaneously and gets all the output data from the left-hand command once it’s there. This makes pipelines tremendously faster than the sequential command execution. I wish I could do this in C++ or Go! It’d be particularly fancy for the flow-based programming. Some languages do support pipes already: F# has real pipes, Go does support data pipelines through channels, etc. I still think pipes are sort of underestimated and I would love to see them everywhere:

//// some neutral pseude-language

// transforms numbers into bottles
func int2bottle(x: int) -> string:
	return fmt.Sprintf("%d bottles of beer", x)

var numbers = [1..10]
var bottles = numbers | filter math.is_prime | map int2bottle

bottles | each fmt.Println
// 2 bottles of beer
// 3 bottles of beer
// 5 bottles of beer
// 7 bottles of beer

The code above is an imaginary listing in some neutral Go/Python-like language. What we do next is we build a data processing pipeline. You ought to read it from left to right as: “take numbers, filter all the primes and map them into bottles”. Concise straight-forward one-liner, utilizing the functions we’ve introduced before. Pipe basically takes the return parameters of the left-hand expression and passes them to the right-hand function. FYI, map and filter are builtin generic functions for the corresponding higher-order functions from the functional languages. When you perform numbers | filter primes, what’s actually being done is filter(numbers, primes). This approach slightly reduces readability (you can’t easily tell amount of the arguments a distinct function recieves/returns), but I think it’s a good trade-off for the a handy pipe syntax.

Why, you might fairly ask, couldn’t I don’t go silly and just use “for” loops? Luckily you’ll find an answer in the next section.

It’s time to hunt some errors!

Now you’ve got an idea of what the optionals and pipes in Aid are and we’re slowly approaching the interesting part of the article: the actual error handling! I really hope you enjoy pipes so far. Remember an example of a trivial filter-map pipeline I’ve shown you in the previous section. Unfortunately, real life has nothing to do with it. In real life we’ve got errors. Let’s expand the pipeline behaviour:

  1. If any of the pipeline expressions return an optional type, left-hand reciever variable must be an optional as well and can’t be ignored.
  2. If a function within a pipeline returns an “invalid” optional (err != nil) set, pipeline execution stops and an empty value with a corresponding error gets returned instead.
  3. If a function within a pipeline returns a “good” optional, it’s actual type is being passed to the subsequent procedure in the pipeline.

This lets us perform a complex pipeline and handle any of the possible errors in the same place once. Frankly speaking, desired behaviour could be achieved with the fluent interfaces, which unfortunately require implementing some state-storing API, which is a tiny headache. Talk is cheap, show me the code!

type FancyError struct {
	errors.Simple

	// Extending with details.
	Flag bool
}

func transform(data []byte) ([]byte ? error) {
	if /*smth*/ {
		return FancyError{"oops", Flag:true}
	}

	return /*transformed*/
}

var cooked? = io.Open filename | io.ReadAll | transform

// switch won't run if err == nil
match cooked {
case io.FileError = err:
	// handling an I/O error
case FancyError = err:
	if err.Flag {
		// ...
	} else {
		// ...
	}
case []byte:
	// anything
}

We’re doing a concise pipeline and switching through the possible errors straight away here. It doesn’t spread error handling logic the way exceptions do and doesn’t require you to write tons of if err != nil boilerplate. How could this be better? I am still sorry for listing all the examples in a non-existing programming language, but I found it the most suitable way to express my thoughts. I really wouldn’t like you readers to be picky about the syntax—it’s highly experimental so far and you shouldn’t really take it seriously.

I sincerely wish you’ll make a use of the concepts I shared today, regardless of me and my imaginary languge. Thanks for your time and reading!