ThoughtWorks
  • 联系我们
  • Español
  • Português
  • Deutsch
  • English
概况
  • 工匠精神和科技思维

    采用现代的软件开发方法,更快地交付价值

    智能驱动的决策机制

    利用数据资产解锁新价值来源

  • 低摩擦的运营模式

    提升组织的变革响应力

    企业级平台战略

    创建与经营战略发展同步的灵活的技术平台

  • 客户洞察和数字化产品能力

    快速设计、交付及演进优质产品和卓越体验

    合作伙伴

    利用我们可靠的合作商网络来扩大我们为客户提供的成果

概况
  • 汽车企业
  • 清洁技术,能源与公用事业
  • 金融和保险企业
  • 医疗企业
  • 媒体和出版业
  • 非盈利性组织
  • 公共服务机构
  • 零售业和电商
  • 旅游业和运输业
概况

特色

  • 技术

    深入探索企业技术与卓越工程管理

  • 商业

    及时了解数字领导者的最新业务和行业见解

  • 文化

    分享职业发展心得,以及我们对社会公正和包容性的见解

数字出版物和工具

  • 技术雷达

    对前沿技术提供意见和指引

  • 视野

    服务数字读者的出版物

  • 数字化流畅度模型

    可以将应对不确定性所需的数字能力进行优先级划分的模型

  • 解码器

    业务主管的A-Z技术指南

所有洞见

  • 文章

    助力商业的专业洞见

  • 博客

    ThoughtWorks 全球员工的洞见及观点

  • 书籍

    浏览更多我们的书籍

  • 播客

    分析商业和技术最新趋势的精彩对话

概况
  • 申请流程

    面试准备

  • 毕业生和变换职业者

    正确开启技术生涯

  • 搜索工作

    在您所在的区域寻找正在招聘的岗位

  • 保持联系

    订阅我们的月度新闻简报

概况
  • 会议与活动
  • 多元与包容
  • 新闻
  • 开源
  • 领导层
  • 社会影响力
  • Español
  • Português
  • Deutsch
  • English
ThoughtWorks菜单
  • 关闭   ✕
  • 产品及服务
  • 合作伙伴
  • 洞见
  • 加入我们
  • 关于我们
  • 联系我们
  • 返回
  • 关闭   ✕
  • 概况
  • 工匠精神和科技思维

    采用现代的软件开发方法,更快地交付价值

  • 客户洞察和数字化产品能力

    快速设计、交付及演进优质产品和卓越体验

  • 低摩擦的运营模式

    提升组织的变革响应力

  • 智能驱动的决策机制

    利用数据资产解锁新价值来源

  • 合作伙伴

    利用我们可靠的合作商网络来扩大我们为客户提供的成果

  • 企业级平台战略

    创建与经营战略发展同步的灵活的技术平台

  • 返回
  • 关闭   ✕
  • 概况
  • 汽车企业
  • 清洁技术,能源与公用事业
  • 金融和保险企业
  • 医疗企业
  • 媒体和出版业
  • 非盈利性组织
  • 公共服务机构
  • 零售业和电商
  • 旅游业和运输业
  • 返回
  • 关闭   ✕
  • 概况
  • 特色

  • 技术

    深入探索企业技术与卓越工程管理

  • 商业

    及时了解数字领导者的最新业务和行业见解

  • 文化

    分享职业发展心得,以及我们对社会公正和包容性的见解

  • 数字出版物和工具

  • 技术雷达

    对前沿技术提供意见和指引

  • 视野

    服务数字读者的出版物

  • 数字化流畅度模型

    可以将应对不确定性所需的数字能力进行优先级划分的模型

  • 解码器

    业务主管的A-Z技术指南

  • 所有洞见

  • 文章

    助力商业的专业洞见

  • 博客

    ThoughtWorks 全球员工的洞见及观点

  • 书籍

    浏览更多我们的书籍

  • 播客

    分析商业和技术最新趋势的精彩对话

  • 返回
  • 关闭   ✕
  • 概况
  • 申请流程

    面试准备

  • 毕业生和变换职业者

    正确开启技术生涯

  • 搜索工作

    在您所在的区域寻找正在招聘的岗位

  • 保持联系

    订阅我们的月度新闻简报

  • 返回
  • 关闭   ✕
  • 概况
  • 会议与活动
  • 多元与包容
  • 新闻
  • 开源
  • 领导层
  • 社会影响力
博客
选择主题
查看所有话题关闭
技术 
敏捷项目管理 云 持续交付 数据科学与工程 捍卫网络自由 演进式架构 体验设计 物联网 语言、工具与框架 遗留资产现代化 Machine Learning & Artificial Intelligence 微服务 平台 安全 软件测试 技术策略 
商业 
金融服务 全球医疗 创新 零售行业 转型 
招聘 
职业心得 多元与融合 社会改变 
博客

话题

选择主题
  • 技术
    技术
  • 技术 概观
  • 敏捷项目管理
  • 云
  • 持续交付
  • 数据科学与工程
  • 捍卫网络自由
  • 演进式架构
  • 体验设计
  • 物联网
  • 语言、工具与框架
  • 遗留资产现代化
  • Machine Learning & Artificial Intelligence
  • 微服务
  • 平台
  • 安全
  • 软件测试
  • 技术策略
  • 商业
    商业
  • 商业 概观
  • 金融服务
  • 全球医疗
  • 创新
  • 零售行业
  • 转型
  • 招聘
    招聘
  • 招聘 概观
  • 职业心得
  • 多元与融合
  • 社会改变
语言、工具与框架软件测试技术

The Either data type as an alternative to throwing exceptions

Mario Fernandez Mario Fernandez

Published: Apr 22, 2020

Exceptions are a mainstay of programming languages. They are commonly used to handle anomalous or exceptional conditions that require special processing, breaking out of the normal flow of the application. Some languages, such as C++ or Java, use them liberally. But not every language follows that design. C# or Kotlin don't have checked exceptions. Others, such as Go and Rust don't even have exceptions at all.

I find code that throws an exception every time something unexpected happens hard to understand and more difficult to maintain. In this article, I want to talk about using the Either data type as an alternative way of dealing with error conditions. I will be using Kotlin for my examples, as I feel the syntax is easy to follow. These concepts are not unique to Kotlin however. Any language that supports functional programming can implement them, one way or the other.
 

Different types of errors

When using exceptions you have to differentiate between the origin of the error, as they are not all equal. Some errors such as NullPointerException, or ArrayIndexOutOfBoundsException indicate bugs. Other errors are part of the business logic. For instance, a validation failing, an expired authentication token, or a record not being present in the database.

I want to talk about the latter. While not being part of the happy path, you still have to decide what to do whenever it happens. Moving the error handling out of the regular code and into some external handler makes it less explicit, as you can only see part of the implementation at a glance. You need more context to understand what's going on. 

In essence, exceptions are hidden goto statements, which are widely considered a lousy idea. The flow of the program is broken whenever an exception occurs. You land on an unknown point up in the call chain. Reasoning about the flow becomes harder, and it is more likely that you will forget to consider all the possible scenarios.
 

What does this usually look like?

Let's use a backend service that provides a REST API as an example. A simple architecture looks like this: a thin controller calls a service, which in turn calls a third party API — this looks testable and straightforward. Until you start dealing with error conditions.



If we don't handle the exception, it will bubble up until our API returns a 500 error to the caller. We don't want that.

A typical pattern in the SpringBoot ecosystem to use an exception handler. You put this method in the controller and it catches exceptions that happen in the chain of calls to services and APIs. Our caller then gets the error formatted the way we want.
 
@ExceptionHandler(JWTVerificationException::class)

fun handleException(exception: JWTVerificationException): ResponseEntity<ErrorMessage> {

    return ResponseEntity

      .status(HttpStatus.BAD_GATEWAY)

      .body(ErrorMessage.fromException(exception))

}
 

When exceptions make the flow harder to understand

Let's say that our service from the diagram above is going to verify JSON Web Tokens. The idea is simple. We are getting a JWT as a string, and we want to know if it is a valid token. If so, we want to get specific properties that we will wrap in a TokenAuthentication. This interface defines it:
 
interface Verifier {

    /**

     * @param jwt a jwt token

     * @return authentication credentials

     */

    fun verify(jwt: String): TokenAuthentication
}
 

A signature that doesn’t quite tell the truth


If we dig into the implementation of the Verifier, we will eventually find something like this:
 
/**

 * Perform the verification against the given Token

 *

 * @param token to verify.

 * @return a verified and decoded JWT.

 * @throws AlgorithmMismatchException 

 * @throws SignatureVerificationException

 * @throws TokenExpiredException

 * @throws InvalidClaimException

 */

public DecodedJWT verifyByCallingExternalApi(String token);

As previously mentioned, Kotlin doesn’t have checked exceptions. As a consequence, however, the signature of Verifier is lying to us! This method might throw an exception. The only way to discover what’s happening is by looking at the implementation. The fact that we have to inspect the implementation to pick this up is a sure sign that encapsulation is lacking.
 

The explicit approach

There are two things I want to change in the Verifier implementation.

1. The verify method shouldn’t throw an exception.
2. The signature of the method should reflect that an error might happen.

We could use a nullable type; verify would then return a TokenAuthentication?. But it has a fatal flaw: We’re losing all the information about what actually went wrong. If there are different causes for the error, we want to keep that information. 

Enter Either (dum dum dum...).
 

The Either data type

Before we talk about Either, what do I mean by data type? A data type is an abstraction that encapsulates one reusable coding pattern.



In our case, Either is an entity whose value can be of two different types, called left and right. By convention, Right is for the success case and Left for the error one. It’s a common pattern in the functional community. It allows us to express the fact that a call might return a correct value or an error, and differentiate between the two of them. The Left/Right naming pattern is just a convention, though. It can help people who have used the nomenclature in existing libraries. You can use a different convention that makes more sense for your team, such as Error/Success, for instance.

We can create a simple implementation using sealed classes. We’ll see below how that can be combined with a when expression to make the code cleaner and safer at the same time.
 
sealed class Either<out L, out R> {

    data class Left<out L, out R>(val a: L) : Either<L, R>()

    data class Right<out L, out R>(val b: R) : Either<L, R>()

}

fun <E> E.left() = Either.Left<E, Nothing>(this)

fun <T> T.right() = Either.Right<Nothing, T>(this)

Let's rewrite our code to make use of Either.
 

Adapting the interface


The Verifier class now returns an Either type to indicate that the computation might fail.
 
interface Verifier {

    /**

     * @param jwt a jwt token

     * @return authentication credentials, or an error if the validation fails

     */

    fun verify(jwt: String): Either<JWTVerificationException, TokenAuthentication>

}

Note that we’re still using the exception class to signal an error, but we aren’t throwing it anymore. Representing that exception as a sealed class can make handling all the possible errors easier for the client, as you can be sure that every condition has been accounted for.
 

Wrapping the code that throws

Inside our implementation of Verifier, we’re wrapping the problematic code with an extension method called unsafeVerify. We use the extension methods that we defined above to create both sides of an Either:
 
private fun JWTVerifier.unsafeVerify(jwt: String): Either<JWTVerificationException, TokenAuthentication> = try {

    verifyByCallingExternalApi(jwt).right()

} catch (e: JWTVerificationException) {

    e.left()

}

This ensures that no more exceptions are thrown. Instead, the method’s returning a Either.Left whenever the verification doesn’t succeed.
 

Using it as a client

The implementation is done. So how do we use this as a caller? We want to decide what to do based on whether the computation succeeded or not.

We can use a when expression thanks to having defined our Either as a sealed class.
 
val result = verifier.verify(jwt)

when (result) {

    is Either.Left -> ResponseEntity.badRequest().build()

    is Either.Right -> ResponseEntity.ok("Worked!")

}

This is equivalent to using the functional fold method. You provide a result for both possible values, thus folding it.
 

Operating on an Either value

I've just shown how to deal with an Either based on its two possible values (left and right). However, we want to also operate on the value throughout our application without having to unwrap and rewrap it each time, as that makes the code hard to read again.

We need to extend Either with two new methods, map and flatMap. Let's start with map:
 
fun <L, R, B> Either<L, R>.map(f: (R) -> B): Either<L, B> = when (this) {

    is Either.Left -> this.a.left()

    is Either.Right -> f(this.b).right()

}

We want to apply a function to our value contained inside the Either. Either is right biased, which means that once it becomes a Left value (i.e: an error), further computations won't be applied. Coming back to our ‘unsafeVerify method, we want to convert the result of that call, which we'll do thanks to our new map method:
 
verifier

    .unsafeVerify(jwt)

    .map { it.asToken() }

We’re still missing one case to cover. What if the operation we want to apply returns an Either itself? If we use map, we'll return an Either of an Either, nesting types until it's impossible to use anymore. To prevent that, we'll add a new method, flatMap.
 
fun <L, R, B> Either<L, R>.flatMap(f: (R) -> Either<L, B>): Either<L, B> = when (this) {

    is Either.Left -> this.a.left()

    is Either.Right -> f(this.b)

}

If you want to dig deeper into these functional concepts, check this article.

 

The arrow library

I provided a simple implementation of Either as an example. A better idea is to use an existing implementation. The excellent arrow library includes an Either type, among many other functional goodies.

One interesting feature of Arrow is that it provides its own flavor of the do notation, a way of making chained operations on types less cumbersome. In Arrow that is referred to as Monad comprehensions.

A normal chain of operations could look like this:
 
request.getHeader(Headers.AUTHORIZATION)

  .toEither()

  .flatMap { header ->

    header.extractToken()

      .flatMap { jwt ->

        verifier

          .verify(jwt)

          .map { token ->

            SecurityContextHolder.getContext().authentication = token

        }

      }

  }

We can get rid of the nested syntax, and make it look like this:
 
Either.fx {

    val (header) = request.getHeader(Headers.AUTHORIZATION).toEither()

    val (jwt) = header.extractToken()

    val (token) = verifier.verify(jwt)

    SecurityContextHolder.getContext().authentication = token

}

I find this easier to read, similar to the async/await syntax for JavaScript promises.
 

Appendix: A built-in solution

Since Kotlin 1.3, there has been a built-in way of dealing with computations that can fail. It is the Result class, which is typically used in a runCatching block:
 
runCatching {

    methodThatMightThrow()

}.getOrElse { ex ->

    dealWithTheException(ex)

}

This class can't be used as a return type yet, so we can't use it for our interface. Moreover, Either integrates nicely with all the other functionality provided by Arrow. In future articles, I plan to write in more detail about other things that you can do with it, such as converting it to an Option or chaining multiple calls in more complex examples.
 

Conclusion

Either is a great way to make the error handling in your code more explicit. Interfaces are clearer about what a call can actually return. Moreover, you can safely chain multiple operations, knowing that if something goes wrong along the way, the computation will short circuit. This is especially useful if you are running data pipelines as a batch operation. There you may not want to bail out on the first error, but rather run the batch in full while accumulating errors and successes.

Making the code more explicit reduces the amount of context that you need to keep in your head, which in turn makes the code easier to understand. Kotlin, combined with Arrow, works beautifully to enable this approach with a lightweight syntax that brings concepts from the functional world in a way that is easy and convenient to use.

Technology Hub

An in-depth exploration of enterprise technology and engineering excellence.

Explore
相关博客
数据科学与工程

Capturing data pipeline errors functionally with Writer Monads

Arun Manivannan
了解更多
技术

Lessons from inheriting another team’s codebase

Birgitta Böckeler
了解更多
语言、工具与框架

How programming languages have evolved

Rebecca Parsons
了解更多
  • 产品及服务
  • 合作伙伴
  • 洞见
  • 加入我们
  • 关于我们
  • 联系我们

WeChat

×
QR code to ThoughtWorks China WeChat subscription account

媒体与第三方机构垂询 | 政策声明 | Modern Slavery statement ThoughtWorks| 辅助功能 | © 2021 ThoughtWorks, Inc.