Quantcast
Channel: All – akquinet – Blog
Viewing all articles
Browse latest Browse all 133

Handling Errors Functionally with Either – A Detailed Guide

$
0
0

Introduction

Developers interact with code that can fail on a daily basis.

The errors that occur are

  • sometimes desired and expected (for example, if they are part of the technical model);
  • and sometimes not (for example, too little memory on the disk or no RAM left).

The developer’s job – in situations where this is appropriate – is to intercept and handle the errors that occur in order to restore the state of the application (where possible).

In practice, this is usually done by interrupting the program flow in the event of an error by throwing an exception. Errors are then handled in Kotlin by inserting a trycatch block (or analogues in other languages) at the appropriate point. Its evaluation takes place at runtime.

This approach is not really ideal for errors of the desired or expected type, as these are already known at compile time and could therefore also be handled at compile time – with corresponding compile-time guarantees that make our code more robust, more predictable, easier to maintain, and easier to test.

In our “Zuverlässigkeit und Sicherheit” (Reliability and Security) Competence Centre, one of the many issues we deal with is how applications can be designed to be reliable and secure at code level. In the following, we therefore present a functional approach to error handling that anchors the modelling of functional errors explicitly in the type system (cue “Either”) in order to not only make them visible to the compiler, but also to enjoy many other advantages of the functional programming paradigm.

Structure

We will discuss a few things in this blog article. So please fasten your seatbelts! It will be a long ride.

  • First, we will take a look at why the Exception-based approach to modelling domain-specific errors, which is still used very excessively in some cases, is actually not an ideal tool.
  • We then derive the abstract data type Either, with which we can model domain-specific errors (or more generally, non-fatal errors) in a type-safe manner and guarantee their handling at compile time. This approach is common in functional languages such as Haskell, but also works wonderfully in non-functional languages such as Kotlin.
  • In the following sections, we examine large parts of the API of Arrow’s Either implementation in detail (the keywords being map and mapLeft, onRight and onLeft, flatMap, getOrElse or merge or fold, the either DSL (with bind, raise, ensure), as well as catch).

Where possible, the explanation is always based on examples that could realistically occur in practice. In particular, the API selection also corresponds to the methods that we use consistently in Either-based projects.

Objectives

Colleagues as well as anonymous people on the Internet have often criticised the fact that the use of Either is not intuitive in practice and is sometimes poorly documented. This is partly true – you have to rethink a little, as the data flow is different from the Exception-based approach. But on the other hand – if you already have a bit of experience in functional programming – then the rethink is not that big… 🤷‍♀️

One important objective of this blog article is to motivate the topic in detail and to lead developers through the relevant API – like a detailed guide, so to speak. Then it turns out that the API is not that big (see above for keywords) and that most methods can be easily memorised based on their signature. 😉

In fact, with the update to version 1.2.0, Arrow’s API has also become quite lightweight and clear.

The other technical objectives are implicitly defined by the three points above (see Structure). If you would like to see a list of specific learning objectives, you are welcome to skip to Conclusion.

Language and Version

Code examples in this article are written in Kotlin 2.0.X. Moreover, version 1.2.4 of the functional library Arrow is used.

However, the Either data type is generally available wherever functional programming is possible, as it is based on an abstract mathematical concept.

The Classic Approach – Exceptions

Usually, error handling in a program using trycatch (or analogues) looks like this:

  • The program fails (expectedly or unexpectedly).
  • An Exception is thrown. The program flow is interrupted at this point.
  • The developer must handle the thrown Exception in order to restore the execution of the program.

So far so good. This pattern is the status quo in most programming languages and is familiar to every developer. 🙂

Working with Exceptions for error handling is intuitive and uncomplicated – otherwise it would not be so widespread. Nevertheless, some developers – especially those who like to write functional code – want to avoid them as much as possible. How can that be?

As it turns out, Exceptions and most errors encountered in practice do not actually go together very well, even from a purely conceptual point of view. Of course, there are certain types of errors where it makes sense and is correct to interrupt the program flow – especially errors of the type “EVERYTHING IS ON FIRE RIGHT NOW” such as OutOfMemoryError… But the vast majority of errors is not so threatening. They are in fact very harmless. And, in contrast to a house fire, quite predictable and desirable.

Domain versus Non-Domain Errors

It makes sense to distinguish between two types of errors: domain errors and non-domain errors.

💡 Domain errors are errors that underlie the domain model and whose handling is an integral part of the application logic.

For example, the domain model could read: “YouTube IDs are strings with certain properties (11 characters and consisting only of letters, numbers, hyphen, underscore).”

Strings that do not follow this pattern would then correspond to a domain error.

Domain errors are expected and predictable. (Of course, there are strings that do not follow the pattern, and these could get around during input validation, for example).

More importantly, however, their potential occurrence is already known at compile time. Handling such errors is the job of the application logic.

…versus…

💡 Non-domain errors are errors that are outside the control of the application logic.

Non-domain errors can be caused by the system (e.g. OutOfMemoryError) or the environment (e.g. network errors).

Errors of this type are often (see the remark below) of an unexpected nature, which makes them difficult to control in some cases.

In the case of a network error, you may be able to try a few retries, but at the end of the day it depends on the availability of the connection. The application can do no more than stop the process in a controlled manner or, if possible, use a fallback value.

In the event of an OutOfMemoryError, the only thing that actually helps is to unplug the application (and double the memory, of course 😉).

If a non-domain error occurs, the program may be in a state that can no longer be meaningfully restored and often requires external intervention.

A small remark concerning the terminology – “Domain” and “expected” or “non-domain” and “unexpected”, respectively, are not synonymous despite the overlaps. Network errors, for example, are certainly an expected event, but are usually not part of the domain model. However, this should not bother us at all. It is worth to use the functional approach for all errors that should not interrupt the program flow. Such errors can and should always be handled gracefully (i.e. in a controlled manner).

To clarify once again – domain errors are part of the model, so their controlled handling is inherent to the application logic. Unhandled domain errors indicate that the technical model might not have been implemented correctly.

This is the point at which the general-purpose tool of Exception-based error handling has some drawbacks. The simplicity of the Exception pattern makes it tempting to handle just everything with trycatch. In this frenzy, however, it is easy to overlook the fact that one error is a house fire and the other is trying to divide by zero – the latter is expected to happen from time to time and (usually) does not require a fire extinguisher.

Why Exceptions and domain errors (or, more generally, errors that should be handled gracefully) do not really go well together is explained below.

I would even go so far as to call it a bad practice. 🤓

Error Handling at Runtime

This point is probably the easiest to understand:

The handling of exceptions only happens at runtime of the program. So why is this a problem?

Because even the most careful developers are not perfect, and mishaps do happen. It is quite common to forget to handle an Exception – often in the depths of third-party code. Then things can go wrong elsewhere later, in the worst case in production.

Things that only become apparent at runtime generally make the program less predictable and more error-prone.

The most important point, however, is that the potential occurrence of domain errors is already known at compile time (and would be handled with Exceptions only at runtime).

To rephrase this a little: Domain-specific Exceptions communicate to the developer at runtime that the program should not have compiled. That sounds a bit… not so ideal?

Good news: There are a few other points that are not so ideal as well. 👍

Poor Compatibility with Strong Type Systems

Strongly typed languages such as Kotlin make life much easier for the developer by ensuring at compile time that the objects appearing in the code are being used correctly. (It’s a bit like shape puzzles for babies…)

In particular, the signature of a function acts as a contract that specifies the expected input and output values. In this way, the compiler can ensure that the function is called correctly – at least as far as the “approximate appearance” of the objects involved is concerned. We benefit from this a lot when refactoring code, because the compiler complains loudly about all the places where things no longer fit together.

The problem with Exceptions in this context is that they bypass the type system. 🤦‍♀️

Let us look at a function that turns a string into an object of type YoutubeId, or throws an Exception in the invalid case.

@JvmInline
value class YoutubeId private constructor(val value: String) {
  companion object {
    private val REGEX = Regex("[A-Za-z0-9_\\-]{11}")

    fun fromString(string: String): YoutubeId =
        if (string.matches(REGEX)) {
          YoutubeId(string)
        } else {
          throw IllegalArgumentException("\"$string\" is not a valid YouTube ID")
        }
  }
}

fun String.toYoutubeId(): YoutubeId = YoutubeId.fromString(this)

The signature YoutubeId.fromString(string: String): YoutubeId or String.toYoutubeId(): YoutubeId suggests that calling the function returns an object of the type YoutubeId. However, this is actually not the case, as the function does not return anything at all for strings that do not correspond to the regular expression – it is instead interrupted with an IllegalArgumentException. The contract “make YoutubeId from string” is not fulfilled. FAKE NEWS! The signature is lying to us.

In particular, when using Exception-based code, the developer can never really be sure from the signature of a function whether it will throw something or not, so that a “manual” inspection of the called function is always necessary (so that Exceptions are not inadvertently passed through unhandled). This is the case especially with third-party code.

In the following, we will discuss two more peculiarities of Exceptions that no longer explicitly refer to domain errors, but are of a more general nature.

Poor Composability

A major advantage of the functional approach to error handling is – as we will see later – that several possibly failing functions can be composed in a quite elegant and natural way (cue “flatMap”).

Multi-step Exception-based programs, whose individual steps can each fail, which in turn each entail different error handling, require nested trycatch blocks (or, if you opt for a single try with a general catch block, some sort of case distinction there).

With the functional approach, the information flow can be linearised in a natural way, making it more readable. In particular, the error handling of the first step can be done at code level before the transformation to the second step.

Hidden Control Flow

If there is one thing that functionally-programming developers do not like, it is side effects and other things that happen outside their own scope.

Exception-based programs have the undesirable property of jumping to the next responsible catch block when an Exception is thrown, which can be anywhere else. This is pretty much the opposite of local code and linear program flow. With every throw, you have to hope that the Exception is caught elsewhere, which may force you to consider code far outside the current scope.

The lines of code skipped when throwing can, for example, lead to resource leaks (unclosed streams, etc.) and leave the application in an inconsistent state if you are not careful.

Depending on the surrounding framework, throwing Exceptions and not catching them again can lead to undesirable side effects, even if it is only the logging of potentially sensitive data.

Functionally handled errors do not exert a hidden control flow and do not trigger any side effects unless this is desired, which makes them easier to control.

Summary

Let us try to summarise all this in a few sentences.

  • Domain errors are an explicit part of the technical model. Handling them is an explicit job of the application logic – or the implementation is most likely incomplete.
  • The type system is used to represent the technical model at code level in order to make it accessible to the compiler. Cooperation with the compiler is desirable because it makes the developer’s work significantly less error-prone.
  • However, modelling domain errors by means of Exceptions does not use the type system.
    • This means that both the compiler and the developer lose useful information that would ideally be included in the function signature.
    • In particular, errors known at compile time are only handled at runtime.
  • Moreover, the flow of Exceptions is non-linear and non-local, which makes them more difficult to compose; they obscure the control flow of the program and they are generally not free of side effects.

We expect the following solutions from the functional approach to error handling:

  • Since functionally-handled errors are an explicit part of the model, they – like the rest of the technical model – are represented by the type system and do not enjoy special treatment. Potentially-failing functions can be recognised as such by their return type.
  • In particular, potentially occurring errors become visible to humans and the compiler.
  • The mathematical structure of the return type allows for a natural way to compose potentially-failing functions.
  • Functional error handling inherits favourable characteristics of the functional paradigm: the code is linear, local, without hidden control flow, and free of side effects. This makes it more readable, maintainable, and testable.

Checked Exceptions

One more quick thought before we continue with the details of the functional approach:

In Java, there are checked Exceptions that simulate similar behaviour (errors in the signature of the function and compile-time guarantees).

When designing Kotlin, an explicit decision was made not to use checked Exceptions – probably because they were perceived as a failed experiment by a large part of the developer community.

One could rightly ask whether this is not contradictory somewhere? So why not adopt the feature if compile-time guarantees are basically a good thing?

The following personal take:

Because it does not require a separate language feature at all. This is precisely why we have an extremely expressive type system (in Kotlin), with which we can model data types as detailed as we wish. The decision to type domain errors on an equal footing with successes is actually only natural (as both are inherent to the model). The decision to discard a redundant (and possibly strictly inferior language feature) seems legitimate and correct to me.

The good thing about the explicit use of the type system is that we realise its advantages naturally and automatically. This concerns, among other things, compatibility with other error handling strategies (see Outlook), or with other data types (for example, the conversion between lists containing errors and errors containing lists).

💡 We simply do not need Exceptions at all for modelling domain errors.

The Functional Approach – Either

In the last section, there was a lot of grumbling about Exceptions. Or actually less about Exceptions per se, but rather about the fact that they are inappropriately used for modelling domain or other easy-to-handle errors.

This section serves to “derive” and introduce the abstract data type Either, which we use in particularly demanding projects for modelling domain errors. For now, we will only explain the idea and what this ominous object is all about.

In the following sections, we will look in detail at the Either API and how it is used in practice.

Use the Type System

In the YoutubeId.fromString function, we model a domain error – that is, the error is explicitly desired by our underlying model (not every string is a YoutubeId by its technical definition), and the occurrence of the error is expected (“enter a YouTube ID in this form field”).

Kotlin has an extremely expressive type system, and the compiler is our friend (there will be another blog article on strong types)… So let’s work with it! We can explicitly represent the error state “not a YoutubeId“, which is part of our model, in the type system.

As a first official act, we replace the somewhat meaningless IllegalArgumentException class with a dedicated error type

data class NotAYoutubeId(val string: String) : GeneralError {
  override val message: String = "\"$string\" is not a valid YouTube ID"
}

(preferably better named 😉), where an error in our model – for the sake of simplicity – is an object with a message field.

interface GeneralError {
  val message: String
}

This primarily makes testing easier, as you no longer have to check against any messages from Exceptions, but have an object of a clearly specified type with an enclosed value at hand.

Instead of using the GeneralError interface or similar, you could of course also subclass Exceptions if you really want to. However, this is less performant because the stacktrace is generated when an Exception is instantiated. More importantly, we do not want to throw anything anyway, so why use Exceptions? 🤷‍♀️

The call Youtube.fromString should now

  • either – if the passed string corresponds to the given regular expression – an object of the type YoutubeId,
  • or – if not – an object of the type NotAYoutubeId.

The return type should therefore be a sum type (a.k.a. union type). Developers with TypeScript experience already know the closely related concept “YoutubeId | NotAYoutubeId“. So every object of this type is, in this particular case, either of type YoutubeId or of type NotAYoutubeId – although I was told that in TypeScript, the union is not necessarily disjoint, which will always be case for Either (as the name suggests).

Anyway, this notation does not exist in Kotlin, so you cannot simply call “YoutubeId.fromString(string: String): YoutubeId | NotAYoutubeId“. Instead, Kotlin’s functional library “Arrow” comes with a generic data type Either that models exactly that, and adds a few useful functionalities (because there is a higher-level abstract concept behind it). 🙂

The Data Type “Either”

Let us take a quick look at how Arrow implements the generic data type Either. (A bit of clutter removed for clarity…)

sealed class Either<out A, out B>

data class Left<out A>(val value: A) : Either<A, Nothing>()
data class Right<out B>(val value: B) : Either<Nothing, B>()

Short explanation:

  • Either is a sealed class. This means that there are only the two subclasses Left and Right as defined above.
  • The out modifier ensures that the subtype relation is retained – i.e. every Right<Int> is automatically also a Right<Number>, and every Left<NotAYoutubeId> is a Left<GeneralError>, etc. (Left, Right, Either are therefore covariant in their respective type parameters).

Every instance of Either<A, B> is thus…

  • either a Left<A> (a “left” state containing a value of type A),
  • or a Right<B> (a “right” state containing a value of type B),
  • but never both at the same time.

The names “Left” and “Right” are chosen abstractly. As we mainly model potentially-failing functions with Either, we could also have defined (for example) “Failure” and “Success” – but theoretically we do not have to limit ourselves to modelling failure or success. Either<Int, String> could literally just be either an integer or a string.

In practice, however, the following convention applies: Left<A> is an error of type A, and Right<B> is a success of type B.

Let us take the YoutubeId example from above again. Then we can rewrite the Exception-based function YoutubeId.fromString to the Either-based function YoutubeId.eitherFromString as follows:

@JvmInline
value class YoutubeId private constructor(val value: String) {
  companion object {
    private val REGEX = Regex("[A-Za-z0-9_\\-]{11}")

    fun eitherFromString(string: String): Either<NotAYoutubeId, YoutubeId> =
        if (string.matches(REGEX)) {
          YoutubeId(string).right()
        } else {
          NotAYoutubeId(string).left()
        }
  }
}

fun String.toYoutubeIdEither(): Either<NotAYoutubeId, YoutubeId> = YoutubeId.eitherFromString(this)

The two functions look almost identical. (Which is not particularly surprising…)

The main difference is:

  • Instead of throwing an Exception in case of an error, a Left<NotAYoutubeId> is returned – so the error is part of the return type.
  • The signature YoutubeId.eitherFromString(string: String): Either<NotAYoutubeId, YoutubeId> also communicates this clearly.

This immediately brings a few advantages.

  • 💯 Type safety:
    Since we explicitly model the error case as a possible return type, we enjoy the benefits of a strong type system – in particular, the compiler knows about the potential failure of a function, so the developer is forced to handle the error. (Examples follow.)
  • 💯 Visual clarity:
    Whenever Either appears as a return type in the signature of a function, the developer knows that this function can fail.
  • 💯 Easier to control:
    When using Either, there is no hidden control flow, and no hard-to-control side effects that may occur when an Exception is thrown.
  • 💯 Better testability:
    Our code is more testable because we do not have to catch thrown exceptions, but have the error class directly at hand as a Left.

Still not convinced? Absolutely understandable! We’re not finished yet either. 😉

We are currently getting better signatures with corresponding compile-time guarantees for the price of an abstract enclosing container (Left<A> or Right<B>). This might be the worst trade deal in the history of trade deals, maybe ever. 🍊📉

So far, we only know how to build the container, i.e. how to create left and right states with a value inside.

We will shed some light on the really exciting things, i.e.

  • how to operate on the containers,
  • how to compose the containers,
  • how to get rid of the containers,

in the coming sections!

Railways, Switches, Happy Path, Unhappy Path

We have just learned how to create Eithers. The call String.toYoutubeIdEither() returns either a Left<NotAYoutubeId> (error) or a Right<YoutubeId> (success). And now? What happens next?

The idea is essentially that we have built a switch by calling String.toYoutubeIdEither(), which be visualised quite well.

This idea is sometimes referred to as “Railway-Oriented Programming” (as coined by Scott Wlaschin).

For potentially-failing functions modelled in this way, we have

  • a “happy path” (success case),
  • an “unhappy path” (error case),

which exist in parallel and independently of each other.

In order to continue with the program, we must always consider both paths. In particular, the error path must always be taken into account so that the compiler becomes happy.

In the following sections, we will look at (in relation to the previous subsection)

  • how to transform the paths separately from each other;
  • how to make a sequence of several switches;
  • how to merge the two paths again.

We do not have to implement anything ourselves. All these functionalities are part of the extension methods provided by Arrow on Either. This is why Either<A, B> is also more powerful than the “structureless” sum type A | B from TypeScript, for example.

Transforming Paths (map and mapLeft)

We will first learn how to continue on the two paths of the recently built switch, i.e. how to transform the values in the event of an error or success, respectively. To do this, we will start with something we all already know.

We Already Know This

Most developers are certainly already familiar with List<A>.map(f: (A) → B): List<B>. Given a list with elements of type A, you can apply a function (A) → B to each value and obtain a list with elements of type B.

This is the natural and intuitive way to lift a function (A) → B to a function (List<A>) → List<B>. We use map to transform the content of the container. The surrounding structure, i.e. the container itself, is retained.

The beauty of this is the presence of certain desirable properties:

  • For example, someList.map(f).map(g) is the same as someList.map(f andThen g). So it makes no difference whether you split a function into two maps, which intuitively makes sense.
  • Or someList.map(identity) is simply identity (where fun <X> identity(x: X): X = x is the function that simply returns the input unchanged), i.e. “doing nothing” element by element corresponds to “doing nothing” in total.

(These and other desirable properties are the reason why evil mathematicians would call List<*> an endofunctor of the type category. But luckily, this is none of our business.)

It is precisely these useful and desirable properties that are important when mapping containers.

Just to be on the safe side – When programming with Either, you do not need to know anything about categories or functors. We do not have to when programming with List as well. 😉 However, the similarity or generalisability of these structures justifies that the underlying definitions are well chosen and meaningful.

The Situation with Either

The situation with Either is nearly identical – just as with List, we have (here) two containers Left and Right, each with (here) one enclosed value.

Let us consider the left case as an example. Given a Left<A> and a function f: (A) → C, then there is also the obvious way (analogous to List<A>) to define Left<A>.mapLeft(f): Left<C> via the assignment Left(a) → Left(f(a)).

Similarly, we lift g: (B) → D to Right<B>.mapRight(g): Right<D> via Right(b) → Right(g(b)). In functional libraries that offer an Either type, “mapRight” is simply called “map”, because by convention the right state is used to model the success case.

The fact that mapLeft and map have meaningful properties – for example, that someRight.map(g1).map(g2) is the same as someRight.map(g1 andThen g2) – follows immediately from their respective definitions.

The definition used, namely Right(b).map(g) = Right(g(b)), can also be seen visually in the fact that the selected path in the diagram below is irrelevant. (“The diagram commutes.”)

We now need to extend the Left<A>.mapLeft and Right<B>.map methods so that they are each defined on the whole type Either<A, B> (instead of just on the Left<A> and Right<B> subtypes). To achieve this, we simply do nothing. 😊

More precisely, we define…

  • Either<A, B>.mapLeft(f: (A) → C): Either<C, B>
    • Left(a) → Left(a).mapLeft(f) = Left(f(a))
    • Right(b) → Right(b) (nothing happens)
  • Either<A, B>.map(g: (B) → D): Either<A, D>
    • Left(a) → Left(a) (nothing happens)
    • Right(b) → Right(b).map(g) = Right(g(b))

With this “natural” extension of the definition, the meaningful and desirable properties of mapLeft and map also apply trivially to the whole type Either<A, B>. (You can easily do the maths.)

Enough theory! Let us take a look at a very concrete example.

An Example

Fortunately, we do not need to worry about implementing this ourselves, because the clever Arrow people have already done that. Indeed, mapLeft and map (as defined in the previous section) are an integral part of the Either API, and using them is just as easy as using map on lists. Because ultimately, as I said before, it is the same abstract concept.

We, the developers, only need to know:

  • mapLeft only transforms the left path (error case),
  • map only transforms the right path (success case).

Before we look at a simple example, let us add a videoUrl method to our YoutubeId class.

@JvmInline
value class YoutubeId private constructor(val value: String) {
  // the other code

  fun videoUrl(): String = "https://www.youtube.com/watch?v=$value"
}

Now the simple example I threatened you with – From String.toYoutubeIdEither(): Either<YoutubeId, NotAYoutubeId> make videoUrlEither(string: String): Either<String, String>, where the video URL should be on the right and the error message on the left.

fun videoUrlEither(string: String): Either<String, String> =
    string
        .toYoutubeIdEither()
        .map { youtubeId -> youtubeId.videoUrl() }
        .mapLeft { error -> error.message }

The function then does the following:

videoUrlEither("haf67eKF0uo") shouldBe
  Right("https://www.youtube.com/watch?v=haf67eKF0uo")

videoUrlEither("garbage") shouldBe
  Left("\"garbage\" is not a valid YouTube ID")

…or, visually…

Really just as easy as on lists, right? 😉

Mark Side Effects (onRight and onLeft)

In principle, nobody prevents you from executing side effects within map and mapLeft. There are enough reasons to do this, because sometimes you might want to log something when an error occurs, or whatever.

If you only want to execute side effects – without transformation – Arrow explicitly offers the methods onRight (note that it is not called “on”) and onLeft, which behave in the same way as map and mapLeft, respectively, but execute the passed function without transforming the object.

As an example, we could make YoutubeId.eitherFromString a little more talkative.

fun eitherFromStringVerbose(string: String): Either<NotAYoutubeId, YoutubeId> =
    eitherFromString(string)
        .onRight { youtubeId -> println("This is indeed a Youtube ID: ${youtubeId.value}") }
        .onLeft { error -> println(error.message) }

Essentially, map/mapLeft and onRight/onLeft are the analogs of Kotlin’s let and also, respectively.

The good thing about this is that onRight and onLeft make it visually clear that side effects are being triggered.

Now let us increase the complexity a little by looking at transformations whose return values are themselves of type Either.

Compose Potentially-Failing Functions (flatMap)

It only gets really interesting when our program runs through several steps, each of which can fail.

Let us look at the hypothetical example of a YouTube download tool that receives a string (e.g. via command line, text field, …) and is supposed to download the video with the corresponding ID. For the sake of simplicity, let us assume two potentially-failing steps.

  • Step 1: Check whether the given string is of the form of a YoutubeId. (Possible error: NotAYoutubeId)
  • Step 2: Download the video. (Possible error: DownloadFailed, for whatever reason)

The program should abort – i.e. return a left state – as soon as the first error occurs, as it would be the case with an Exception-based program. This is what we call the fail-fast strategy.

The Technical Model

The video download can fail for various reasons (video does not exist, video cannot be accessed, something very weird and unexpected happening…)

A good approach in this scenario is to define DownloadFailed as a sealed class that implements GeneralError. The individual reasons for the download to fail then correspond to the individual subclasses of this sealed class. This has the great advantage that the compiler knows, because of the sealed modifier, that there are no other error reasons apart from those defined there. In other words, all possible download errors are known at compile time.

sealed class DownloadFailed(override val message: String) : GeneralError

data class VideoNotFound(val youtubeId: YoutubeId) :
    DownloadFailed("Video not found: ${youtubeId.videoUrl()}")

data class VideoNotAccessible(val youtubeId: YoutubeId) :
    DownloadFailed("Video not accessible: ${youtubeId.videoUrl()}")

// other reasons why the download might fail

data class UnexpectedDownloadError(val youtubeId: YoutubeId, val throwable: Throwable) :
    DownloadFailed("An unexpected error occurred while trying to download ${youtubeId.videoUrl()}: ${throwable.message}")

A successful download could, for example, return a few selected video metadata.

data class VideoMetadata(
  val youtubeId: YoutubeId,
  val title: String,
  val duration: Duration,
)

And the download method of a hypothetical YouTube download service could then (abstractly) look like this:

interface YoutubeDownloadService {
  fun downloadVideo(youtubeId: YoutubeId): Either<DownloadFailed, VideoMetadata>
}

The common supertype of NotAYoutubeId and DownloadFailed is GeneralError in our model. The composite function should therefore have the signature (String) → Either<GeneralError, VideoMetadata>.

Why “map” is Not Enough

We recently learned about the method Either<A, B>.map(g: (B) → D): Either<A, D>.

After the first step, we have an Either<NotAYoutubeId, YoutubeId> at hand, where YoutubeId is the happy path.

If we were to simply map YoutubeDownloadService.downloadVideo to it, we would have a nested object of type Either<NotAYoutubeId, Either<DownloadFailed, VideoMetadata>>, or more generally Either<GeneralError, Either<GeneralError, VideoMetadata>>.

So we would have two switches with a total of three outputs.

But actually we want a total of two outputs.

In order to flatten the nested Either (hence “flatMap” – quite analogous to the “flatMap” on lists, which…
flattens nested lists 🤔), we simply combine the two paths NotAYoutubeId and DownloadFailed into a common path GeneralError.

This leads us to the following definition of the flatMap method.

Definition of “flatMap”

Programmatically, it looks like this after the first step:

  • In the Left<NotAYoutubeId> case, simply do nothing. The result is then automatically a Left<GeneralError> by covariance.
  • In the Right<YoutubeId> case, take the YoutubeId and calculate Either<DownloadFailed, VideoMetadata>. The result is then either a Right<VideoMetadata> or also a Left<GeneralError>.

Overall, we end up with an Either<GeneralError, VideoMetadata> as desired.

Now we abstract A = GeneralError, B = YoutubeId, D = VideoMetadata.

The function Either<A, B>.flatMap(G : (B) → Either<A, D>): Either<A, D> is then intuitively defined by

  • Left(a) → Left(a) (nothing happens)
  • Right(b) → G(b)

The fact that this truly makes sense can also be explained in this way:

  • In case of an error (left state), there is nothing more to do. The program is over.
  • In case of a success (right state), we go to the next switch, which is the result of the function G.

Definition done. 😊🎊

Our downloadVideoFromString method could eventually look like this:

context (YoutubeDownloadService)
fun downloadVideoFromString(string: String): Either<GeneralError, VideoMetadata> =
   string.toYoutubeIdEither().flatMap { youtubeId -> downloadVideo(youtubeId) }

The abstract concept “flatMap” is, in my own experience, the most difficult to understand (especially with even more complicated data types such as Ior)… But once you got behind it, you may feel even more enlightened. 🧠💡

Why There is no “flatMapLeft”

A hypothetical method “flatMapLeft” makes no sense on the Either data type in the context of functions that should fail on the first error.

The idea of executing potentially-failing functions one after another is that further errors can only occur when continuing along the happy path.

However, once we are on the unhappy path, we can only recover from the error by merging the two paths (see next section) – but there is no going back to the happy path while the unhappy path still exists. Because it makes no sense.

In short: Error occurs -> program aborts (fail-fast).

We will come back to flatMap in a moment because things can get unappealing pretty fast. But this is another topic, so let us first take a look at how the paths can be merged.

Merge the Paths (getOrElse, merge, fold)

We now know how to transform and compose our Either containers. What remains to be clarified is how to get the value out of the container again… A hypothetical method Either<A, B>.get(): B is unfortunately too naive and does not work, because Either<A, B> only signals that there are two paths – but which path, i.e. whether there is a Left<A> or a Right<B>, is a question that can only be answered at runtime.

💡 Then how do we get to the success value (B) at compile time? By handling the error case (A)!

This is where the great advantage of functional error handling becomes apparent. From a purely technical point of view, it is impossible to extract the B from the container without taking care of the A. This means that the program will not compile if the error is not handled! Conversely, we have a compile-time guarantee on our error handling. 🎊

Arrow comes with some related methods that can be used to merge the two paths.

As an example, we take our function downloadVideoFromString: (String) → Either<GeneralError, VideoMetadata>, and turn it back into a string that contains an informative sentence in case of success, and the error message in case of error. (“In real life” this could, for example, be a Spring ResponseEntity instead of a string.)

getOrElse

The idea of Either<A, B>.getOrElse(default : (A) → B): B is quite simple and already clear from the signature.

  • In the Right<B> case, extract the B.
  • In the Left<A> case, transform the A to a B using default a.k.a. handle the error with a fallback value.

Our function downloadVideoFromStringResult: (String) → String could then be implemented like this:

context (YoutubeDownloadService)
fun downloadVideoFromStringResult(string: String): String =
  downloadVideoFromString(string)
    .map { "Downloaded \"${it.title}\" from ${it.youtubeId.videoUrl()}" }
    .getOrElse { error -> error.message }

But there are also very similar alternatives.

merge

The method Either<A, A>.merge(): A is only defined on Eithers with the same type left and right. In this case, the value is simply extracted, as it does not matter whether it comes from the left or the right. With merge, our function downloadVideoFromStringResult can also be written alternatively as:

context (YoutubeDownloadService)
fun downloadVideoFromStringResult(string: String): String =
  downloadVideoFromString(string)
    .map { "Downloaded \"${it.title}\" from ${it.youtubeId.videoUrl()}" }
    .mapLeft { error -> error.message }
    .merge()

This is somewhat more symmetrical at the expense of an additional function call.

fold

The method Either<A, B>.fold(ifLeft : (A) → R, ifRight : (B) → R): R takes two transformations to a common return type R as arguments instead.

context (YoutubeDownloadService)
fun downloadVideoFromStringResult(string: String): String =
  downloadVideoFromString(string)
    .fold(
      ifRight = { "Downloaded \"${it.title}\" from ${it.youtubeId.videoUrl()}" },
      ifLeft = { error -> error.message },
    )

For transformations that are longer than one line, fold may be a little more difficult to read due to the indentation, but this way you only need one function call at top level.

Summary

Which of the options getOrElse, merge, or fold to use is a question of readability in the given situation, and of course also of personal preference.

However, the idea is always the same – turn two paths (success and error) into one result.

Getting rid of the Either (surrounding frameworks such as Spring, for example, do not know Either) means exactly that the error has been handled – and this at compile time.

And the developer is guaranteed by the type system and compiler to have handled all errors as soon as the Either disappears from the signature again. 🎊

The “either” Block

A moment ago, we have seen how flatMap can be used to compose multiple potentially-failing functions.

As it turns out, this can become an unpleasant experience pretty fast. Here is why.

When “flatMap” Becomes Hard to Manage

For programs that consist of two or three potentially-failing steps where the data is simply passed through, flatMap is nice and easy to use. But what happens if we change and expand our technical model a little?

Our YouTube download tool should now do the following:

  • Step 1: Check if the given string is of the form of a YoutubeId. (Possible error: NotAYoutubeId)
  • Step 2: Get the metadata of the corresponding video without downloading the video. (Possible error: DownloadFailed, for whatever reason)
  • Step 3: Check certain criteria. (Possible error: InappropriateVideoMetadata, for whatever reason)
  • Step 4: Download the video. (Possible error: DownloadFailed, for whatever reason)

(Note that I will reuse the DownloadFailed error class for the sake of simplicity. It might make sense to use a different error model for the metadata fetching if this were a real application.)

The accordingly extended YoutubeDownloadService then looks like this:

interface YoutubeDownloadService {
  fun fetchMetadata(youtubeId: YoutubeId): Either<DownloadFailed, VideoMetadata>

  fun downloadVideo(youtubeId: YoutubeId): Either<DownloadFailed, VideoMetadata>
}

Our error model for checking the metadata (for simplicity, it is just VideoTooLong) looks like this:

sealed interface InappropriateVideoMetadata : GeneralError {
  val videoMetadata: VideoMetadata
}

data class VideoTooLong(val maxDuration: Duration, override val videoMetadata: VideoMetadata) :
  InappropriateVideoMetadata {
  override val message: String =
    "The duration ${videoMetadata.duration} exceeds the maximum duration of $maxDuration"
}

And the logic that actually checks the metadata looks like this:

data class VideoMetadataEvaluator(val maxDuration: Duration) {
  fun evaluateVideoMetadata(
    videoMetadata: VideoMetadata
  ): Either<InappropriateVideoMetadata, VideoMetadata> {
    if (videoMetadata.duration > maxDuration) {
      return VideoTooLong(maxDuration, videoMetadata).left()
    }

    return videoMetadata.right()
  }
}

Then our expanded program looks as follows:

context (YoutubeDownloadService, VideoMetadataEvaluator)
fun downloadVideoFromString(string: String): Either<GeneralError, VideoMetadata> =
    string
      .toYoutubeIdEither()
      .flatMap { youtubeId -> fetchMetadata(youtubeId) }
      .flatMap { videoMetadata -> evaluateVideoMetadata(videoMetadata) }
      .flatMap { videoMetadata -> downloadVideo(videoMetadata.youtubeId) }

A few many flatMaps, but so far nothing special, because the data is simply passed from top to bottom in easy-to-digest chunks.

But what if we changed (for example) evaluateVideoMetadata to return Right<Unit> instead of Right<VideoMetadata>? “In real life” it may not always be the case that we can simply pass the data on.

Then the chain would be broken, as we would have no access to videoMetadata.youtubeId in the scope of the last flatMap. As a result, we would have to nest the flatMaps as follows:

context (YoutubeDownloadService, VideoMetadataEvaluator)
fun downloadVideoFromString(string: String): Either<GeneralError, VideoMetadata> =
    string.toYoutubeIdEither().flatMap { youtubeId ->
      val metadata = fetchMetadata(youtubeId)
      metadata
          .flatMap { videoMetadata -> evaluateVideoMetadata(videoMetadata) }
          .flatMap { downloadVideo(youtubeId) }
    }

This can quickly become very ugly. 😐 Also, visually, it is not better at all than nesting trycatch blocks.

Fortunately though, there is a solution to this problem! 👍

The “either” Computation

In Arrow, there is a lowercase either function.

Writing either { ... } introduces a code block that represents a computation, the result of which is an Either object. This block is also about combining several potentially-failing functions into a single potentially-failing function, just like with flatMap. The special thing about the either block, however, is that its content looks almost exactly like Exception-based code, but

  • the type safety and compile-time guarantees of Either are preserved,
  • there is no need to nest anything at all, since you can always transform the intermediate steps within the calculation by using mapLeft instead of transforming the result at the end.

Let us take a closer look at the either block using our previous example downloadVideoFromString : (String) → Either<GeneralError, VideoMetadata>.

bind

Within (and only within) an either block, you can call bind() on values of the type Either<A, B> (i.e. on the results of potentially-failing functions). This call does the following:

  • In the case Left<A>, the computation terminates. The return value of the either block is the bound Left. (A.k.a. the program ends with the first error).
  • In the case Right<B>, the value of type B is unpacked, and the computation continues.

If the computation runs without a Left being bound, the result of the either block is the last value in the computation packed as Right.

The functionality is therefore the same as for flatMap. However, the code looks much simpler and more familiar.

context (YoutubeDownloadService, VideoMetadataEvaluator)
fun downloadVideoFromString(string: String): Either<GeneralError, VideoMetadata> = either {
  val youtubeId = string.toYoutubeIdEither().bind()

  val videoMetadata = fetchMetadata(youtubeId).bind()

  evaluateVideoMetadata(videoMetadata).bind()

  downloadVideo(youtubeId).bind()
}

Local error handling (or logging of certain errors, for example) can simply be placed before the bind at the appropriate point via mapLeft or onLeft instead of at the end, and without any nesting.

Another advantage is that every bind() call visually indicates that a potentially-failing function is being called there, which is why I would not call it “boilerplate”.

The either block has some more methods to look at though.

raise

If you call the function raise(a: A) within an either block, the computation ends with the result Left(a). So you can think of raise as Arrow’s analogue to throw. Except that nothing is thrown, but everything is functionally encapsulated.

The companion method YoutubeId.eitherFromString, for example, could also be implemented in this way:

fun eitherFromString(string: String): Either<NotAYoutubeId, YoutubeId> = either {
  if (!string.matches(REGEX)) {
    raise(NotAYoutubeId(string))
  }

  YoutubeId(string)
}

This also looks very similar to the code you would normally write.

ensure and ensureNotNull

Alternatively, you can also use ensure, which acts as Arrow’s analogue to Kotlin’s require. The first argument of ensure is the condition to be checked, and the second is a function () -> A that eventually produces a Left<A> if the condition is not fulfilled (with which the either block then ends).

fun eitherFromString(string: String): Either<NotAYoutubeId, YoutubeId> = either {
  ensure(string.matches(REGEX)) { NotAYoutubeId(string) }

  YoutubeId(string)
}

Worth mentioning – The ensureNotNull method works in the same way (value is null → block ends with a given value as Left), but also makes the value non-nullable, i.e. casts a success value of type B? to B.

Whether you prefer to use raise or ensure (here specifically) is a matter of preference. However, raise is quite practical to use in when branches, because ensure only works for checking conditions.

Summary

Using Arrow’s either block, we can write Either-based code in a way that looks very similar to the sequential code we are used to, but without giving up the type safety and compile-time guarantees of Either.

In particular, we can write composite programs of potentially-failing functions without having to use hard-to-read nested flatMap constructs. (Or, even worse, harder-to-read nested trycatch constructs).

We combine the best of both worlds, so to speak – the intuitiveness of the throw world and the soundness of the Either world.

Integrate Exception-based Code (catch)

It is extremely sad but the world out there is not just functional. 😢 As soon as you use third-party APIs, you usually have to deal with functions that are Exception-based. In order to integrate those into your own Either-based ecosystem, the following approach is a good idea:

// someFunctionThatThrows : () -> SomeResult, throws SomeException
val result: Either<SomeException, SomeResult> =
   try {
     someFunctionThatThrows().right()
   } catch (e: SomeException) {
     e.left()
   }

The Exception is not thrown, but returned as a Left instead. (And the success value as a Right.) Arrow provides the Either.catch method for this.

val result: Either<Throwable, SomeResult> = Either.catch { someFunctionThatThrows() }

It is worth mentioning that Either.catch is limited to non-fatal errors. Exceptions such as a CancellationException, which in Kotlin signals the normal termination of a coroutine, are let through and thrown.

This approach roughly corresponds to our distinction between different types of errors at the beginning. Because, as mentioned before, for some Exceptions it is legitimate and good that the program flow is stopped.

Note, however, that we lose information about the type of the caught Exception by calling Either.catch (it does not have a generic type argument for some reason), so it is a good idea to use mapLeft in order to translate the caught Exception into your own functional error model.

In one of our company’s projects, for example, we have a predefined and precisely specified error model (a.k.a. ServiceError). The scenario “transform an Exception from third-party code to a ServiceError” occurs often enough that we have extracted it as a pattern.

suspend fun <L : ServiceError, R> catchAndCauseServiceError(
  message: String,
  transformation: (String, ErrorCause) -> L,
  f: suspend () -> R,
): Either<L, R> = catch { f() }.mapLeft { transformation(message, it.toErrorCause()) }

For example:

catchAndCauseServiceError(
    message = "Schema validation failed",
    transformation = BvlError::ValidationFailed,
  ) {
    validator.validate(StreamSource(bvlDocument.inputStream()))
  }
  .bind()

Tests with Either

We had already established relatively early on that the functional approach to error handling automatically leads to good testability of the written code due to the explicit modeling of an error state via the type system, as in the error case you have the Left and its value at hand (instead of having to throw and catch an Exception).

For the standard test framework “kotest” (https://kotest.io/) there are a number of matchers that are tailored to Arrow.

You will need the “Kotest Arrow Extensions” dependency – see also https://mvnrepository.com/artifact/io.kotest.extensions/kotest-assertions-arrow.

The relevant matchers shouldBeRight and shouldBeLeft can be used…

  • as a call without an argument, which also casts the result to Right or Left, respecitvely;
  • as an (infix) call with one argument, which does the same and additionally checks the contained value against the passed argument.

A test could then look like this.

class YoutubeIdTest :
    DescribeSpec({
      it("is a valid YoutubeId") {
        val subject = YoutubeId.eitherFromString("haf67eKF0uo")

        val youtubeId = subject.shouldBeRight()
        youtubeId.value shouldBe "haf67eKF0uo"
      }

      it("is not a valid YoutubeId") {
        val subject = YoutubeId.eitherFromString("garbage")

        val error = subject.shouldBeLeft()
        error shouldBe NotAYoutubeId("garbage")

        // alternatively
        subject shouldBeLeft NotAYoutubeId("garbage")
      }
    })

Conclusion

🎊🎊🎊

Anyone who has made it this far and has not yet died knows the following by now if they did not yet before:

  • What the difference is between domain and non-domain errors. (Or more generally, that we want to distinguish between errors that are expected and can easily be handled gracefully, and errors that lie outside of the scope of our application logic and should rightfully interrupt the program.)
  • Why Exceptions are not a good tool for modelling the errors of the first type:
    • They bypass the type system that is used to represent the model in the code in which domain errors are explicitly contained.
    • In particular, they bypass the contract specified by the signature of a function, making it quite worthless.
    • They shift information from compile time to runtime, making the application more error-prone.
    • They distort the control flow of the program, making the developer’s life more difficult.
  • How error states can be modeled explicitly via the type system using Either, and what advantages this brings:
    • Both humans and the compiler know about the potential occurrence of errors.
    • The program flow is linear and free of side effects. The code is therefore easy to read, easy to maintain and easy to test.
  • How to use Either in practice:
    • How to build a switch with two paths.
    • How to transform the individual paths (map and mapLeft).
    • How to trigger side effects in an encapsulated way (onRight and onLeft).
    • How to compose several switches (flatMap).
    • How to merge multiple paths back into one path (getOrElse, merge, fold).
    • How to follow the functional approach and still write familiar code (either DSL with bind, raise, ensure).
  • How to use Exception-based code within Either-based code (catch).
  • How to test Either-based code.

In any case, we know enough to be able to use Either confidently in future projects. 👍

Outlook

Even though the blog article has already reached a certain length (which was to be expected), there is still room for more. 😄

Arrow has a lot to offer in the context of functional error handling. Let us take a glimpse at just a part of it.

Accumulating Errors

In this blog article, we have dealt exclusively with the fail-fast strategy of error handling. (The program ends when the first error occurs.)

In some situations, however, this is not desirable. This could be the case, for example, if there are several form fields to be validated, whose error messages should be accumulated.

Arrow also provides tools for this, which we can look at later (cue “EitherNel“).

Other Data Types

We now know a lot about Either! But there are also other data types in Arrow.

The Option<B> data type can be used to model the absence or presence of a value of type B. This is weaker than what Either<A, B> models (more precisely, every Option<B> is essentially an Either<Unit, B>), which is why there is less to write about it. Kotlin also already provides this model in the form of B? (nullable types) even without additional libraries. In practice, this does not really make much difference and is a matter of preference. Option tends to interact better with Either, however.

Arrow also includes the data type Ior<A, B> (a.k.a. Inclusive Or), which models error or success or both at the same time. Each Ior<A, B> is therefore either a Left<A> or a Right<B> or a Both<A, B>. This model makes sense if errors occur during the execution of a program, but do not prevent a successful completion. It is thus somewhat more complicated, but also more interesting. In particular, the definitions flatMap (or bind) for Ior are more difficult to understand than those for Either – but just as with Either, they are quite logical.

Summary

Whether accumulating errors or simultaneous error and success states – you can still get a lot out of Arrow. (And even more than that…)

Both are advanced scenarios that do occur “in real life” but cannot be modeled using throw. In these situations, you need alternative solutions anyway, and the solution of functional error handling via the type system using Arrow scales wonderfully.

But these are topics for other potential blog articles. 😊

Last Words

Thank you for reading this extensive blog article so carefully. 🥺

I personally find the functional type-safe approach to error handling using Either very useful and elegant, and consider it a good paradigm to achieve reliability and security of our code in close cooperation with the compiler.

Also I really find it much cleaner than using Exceptions.

Furthermore, I would like this blog article to…

  • help developers understand the benefits and practical application of Either when they may have previously felt overwhelmed by it;
  • inspire developers to actually use Either in their projects.

Until the next article. 👋


Viewing all articles
Browse latest Browse all 133

Trending Articles