Don't Repeat Yourself

Don't Repeat Yourself (DRY) is a principle of software development aimed at reducing repetition of all kinds. -- wikipedia

Kotlinの新しいエラーハンドリング「Rich Errors」

先日より開催されていたKotlinConfで、新しいエラーハンドリング「Rich Errors」についての言及がありました。従来のように例外を使用するのではなく、エラーを値として扱えるようにする新機能です。聴き逃しているだけかもしれませんが、まだリリース予定などは立っておらず、機能を設計中の段階なのではないかと思われます。

私はもともとKotlinのエラーハンドリングはなかなか悩ましいなと思っていた節があり、ずいぶん前からKEEP上での議論を追うなどしてキャッチアップしていました。最近同僚ともエラーハンドリングに関する決め事をあれこれするために議論しており、私自身はエラーハンドリングに対する関心が高いです。

私の所感としては、Kotlinの言語設計によく合ったエラーハンドリングの方式が採用されそうで非常に楽しみにしています。最近の議論ではともすればモナドであるとか、Result<T, E>型がどうという話になってしまいがちですが、言語設計によってそれらの手法が有効かどうかは大きく変わります。その点、今回発表されたエラーハンドリングはKotlinの言語仕様によく適合しているのではないかと思いました。

KotlinConfでの発表は、下記の動画で見ることができます。

www.youtube.com

既存のエラーハンドリングの課題点

まず簡単な用語の整理ですが、この記事で「エラーハンドリング」と表記するときは、「何かしらのエラーが発生し、それをハンドルする一連の機構」のことを指しています。なので、文脈によっては「例外」のハンドリングを含むこともあります。できる限り何の話をしているかを最初に明示するようにしますが、先に断っておきます。

Kotlinの従来のエラーハンドリングは、Javaの例外のtry-catchとほとんど同義でした。すなわち例外を使ってエラーハンドリングを行っていました。Kotlinには標準ライブラリにResult<T>型がありますが(紛らわしいことに)、これも実質的に例外のハンドリングに関するAPIです。

従来の検査例外を使ったエラーハンドリングには、下記のような課題があったと発表では指摘されています。

まずtry-catchで例外を上に伝播させるだけのときに、非常にボイラープレートが多く感じられます。Kotlinでは例外を逐一キャッチせずとも検査例外であろうと勝手に上に伝播させますが、Javaの場合は非常にボイラープレートが増えることでしょう。

Javaの場合さらに、高階関数の中で検査例外を投げた場合、高階関数の外に例外を伝播させる方法が、標準ではありません(注: Java8くらいの記憶)。たとえば高階関数内で非検査例外に変換してしまうか、あるいは@FunctionalInterfaceを使って例外を伝播させられる実装を自前で用意してやる必要があります。

紹介した既存の例外によるエラーハンドリングの課題点を解決するためにとられる方法のひとつとして、「エラーを値として表現する」というものがあります。要するに、エラー側の値も通常の処理フロー内で同じように扱うということです。この方式のメリットは、たとえば次のものが挙げられます。

  • 特別なコントロールフローを使わずともエラー時の処理を記述することができる。
  • (Kotlinの場合特にだが)関数から返される可能性のあるエラーの情報を型に落としておくことができる。
  • 特別なコントロールフローを必要としないこと、また型情報にすべて落ちてくることから、ラムダ式や非同期処理において発生していた問題を回避することができる。

Kotlinはしばらく、こうした問題に有効な対応策を打てていなかったように思います。その結果、サードパーティ製のResult型によるハンドリングが出てくるなどしました。Kotlinの本体はエラーハンドリングに迷っている感じさえ見受けられ、標準ライブラリにはResult<T>という、正直なところ使う場面を選ぶ機能が実装されるなどしてしまいました。

「エラーアトリビュート」 vs 他の言語のエラーハンドリング

近年多くのプログラミング言語Result<T, E>型の考え方がとりいれられ、ライブラリが作られています。Swiftのように、プログラミング言語の機能としてResult型を提供するようになったものも出てきています。Rustは標準で搭載されていますし、ScalaEither[E, T]の形ではありますが、ほぼ同じものが導入されています。

Result型というのは、成功と失敗の両方の状態になりうることを示す型です。たとえばRustの場合、Result型は次のように定義されています。

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Result型はエラーの伝播のさせかたについていくつか流派があるように見受けられます。たとえば、

  • パターンパッチで失敗時の情報を取り出し、関数の返り値として改めて返す。
  • Rustのように?演算子のような文法機能を用意しておき、エラー発生時にはその行で処理を中断させ、エラーを返す。
  • ScalaHaskellのように、Result型(Either型)の実装それ自体をモナドにしておき、do記法と組み合わせて実装する。いくつかのResult型を返す関数のチェーンがあったとき、チェーンの途中でエラーが発生すると、それ以降はすべてエラーとみなして処理を実質的に中断させる。
  • ScalaHaskellのようにモナディックな実装やdo記法を持たない言語の場合、素朴にコンビネータ(filter, map, flatMap, foldなど)でResult型同士をチェーンさせるか、専用のDSLを用意して、擬似的にモナドのような書き心地を再現する。kotlin-resultなどが代表的にはそれにあたる。

Either型を導入する場合、強力なパターンマッチか高階カインド型ないしはdo記法のような専用記法の導入もセットでついてくることが多いでしょう。これらがない場合、むしろ片手落ちであるとすら私は感じます。

しかし、Kotlinはそのどちらも持っていません。パターンマッチに似た機能としてwhenを持っているものの、私はあれはスマートキャストを活用したswitchであって、パターンマッチではないと思います。高階カインド型やdo記法は当然持ち合わせていません。

また、両者を入れるとコンパイラの実装や型システムを相当変える必要がでてきます。パターンマッチを入れる場合、flow-sensitiveなKotlinの既存の型システムとは非常に相性が悪いでしょう。また、高階カインド型はKotlinの型システムを大幅に拡張する必要があるでしょう。flatMapなど一部のコンビネータを特別視するdo記法の代替品を導入する可能性はなくはないと思いますが、高階カインド型とセットでないのならあまり入れる旨味はないかもしれません。

上記に関連して、RustのResult型は私の中では理想に近いです。?演算子は手続き的な書き心地を失うことなく、エラーの伝播時のボイラープレートを大幅にカットしている優れた手法だと考えています。ただし、RustのResult型のエラーハンドリングがうまく機能するのは、私の考えでは、Rustが強力なパターンマッチを持つからだと思います。なので、同等のパターンマッチの機構を持たないプログラミング言語では、Result型の導入はやや手間を増やす結果に終わるでしょう。この点は、Kotlinのwhenと今回導入されるエラーハンドリング手法の相性の良さについて説明する際、改めて触れることにします。

その他のエラーハンドリング方法として有力なのは、Goのように多値の中にエラー情報を返させる手法でしょう。よく見る(T, error)です。この手法はやはり、エラーを値として扱う際の第一候補にはなりうるように見受けられるものの、エラーの伝播時に大量のボイラープレートを発生させることになります。常にそうだというわけではありませんが、多くのケースでif err != nilが必要になります。これは、例外のときに解消したい問題の一つであった、ボイラープレートの多さという課題を解決できません。

こうした背景を踏まえ、Kotlinの既存の型システム––つまりflow-sensitiveでスマートキャストを中心に据えたデザインと私は思っていますが––に上手に適合するエラーハンドリングとして提案されたのが、「エラーアトリビュート(Error Attributes)」を使ったハンドリング方法です。要するに、型に対してエラーに関する付加情報を付与するというものです。実際の型のシグネチャT | ErrorErrorがエラーアトリビュートに該当する)となり、ユニオン型に近い見た目になります。ややこしいですが、Int | Stringのような記法はエラーハンドリングとして使用することはできないようです。

この手法を導入すると、Kotlinの既存の文法機能とうまく適合しつつ、ボイラープレートの少ないエラーハンドリングを実現できそうと発表からわかりました。カンファレンスで発表された段階では、まだまだデザインの基本的な方針が決まったという感じがしており、今後細かい部分の方針が変わる可能性はあります。が、概ねエラーアトリビュートを使うことそのものは決まっていそうでした。

一方でこの手法も銀の弾丸というわけではなく、トレードオフから逃れることはできません。いくつかの新機能の導入と若干の型システムへの変更が必要なようですし、いくつかコーナーケースや制約が発生することにもなるようです。「なぜできないのか」を言語仕様のレベルから理解しなければ、説明が難しいものもあるように思いました。次の節で、エラーアトリビュートによるエラーハンドリングが、なぜKotlinの既存の文法機能や型システムとよく適合するのかなどの話題を深掘りしていきたいと思います。

エラーアトリビュートを使う良さ

Result型を採用した場合と比較して

話が紛らわしいので少し前提を整理しておくと、Kotlinの標準ライブラリに入っているResult<T>型ではなく、Result<T, E>型を指しています。kotlin-resultなどが代表例ですが、kotlin-resultも最近実装が少し変わってしまい、微妙に私が今回議論したいものとは異なっているため、あくまでRustで見られるResult<T, E>型をそのまま導入したらどうなるかという前提に立ちます。

KotlinでResult型を導入すること自体は既存の文法機能で実現可能です。たとえば次のように実装できるでしょう。実は最近Kotlin向けのResult型のライブラリを改めて実装したのですが、そのときには次のように実装すればワークすることがわかっています。

sealed interface Result<out T, out E> {
    /**
     * Represents a successful result containing a value.
     *
     * @param T The type of the contained value
     * @property value The success value
     */
    @JvmInline
    value class Ok<T>(
        val value: T,
    ) : Result<T, Nothing>

    /**
     * Represents a failed result containing an error.
     *
     * @param E The type of the contained error
     * @property error The error value
     */
    @JvmInline
    value class Err<E>(
        val error: E,
    ) : Result<Nothing, E>
}

(2025/08/04: この部分の記述を修正しています)

これをwhenでスマートキャストにかけると次のように実装することができます。最大限にこの機能のメリットを受けようとすると、whenを2回重ねる必要が出てきます。こうしないと網羅性チェックが上手に働いてくれないためです。

sealed interface FetchError {
    data object FailedToConnectServer : FetchError
    data object FailedToValidateUser : FetchError
}

fun fetchUser(): Result<User, FetchError> { ... }

fun main() {
    when (val result = fetchUser()) {
        is Ok -> TODO() // 何らかの処理
        is Err -> when (result.error) { // もう一回whenをかけないと取り出せない
            is FetchError.FailedToConnectServer -> TODO()
            is FetchError.FailedToValidateUser -> TODO()
        }
    }
}

スマートキャストを利用しなければいいかもしれません。すると、下記のように記述することはできます。

sealed interface Result<out T, out E> {
    /**
     * Represents a successful result containing a value.
     *
     * @param T The type of the contained value
     * @property value The success value
     */
    @JvmInline
    value class Ok<T>(
        val value: T,
    ) : Result<T, Nothing>

    /**
     * Represents a failed result containing an error.
     *
     * @param E The type of the contained error
     * @property error The error value
     */
    @JvmInline
    value class Err<E>(
        val error: E,
    ) : Result<Nothing, E>
}

sealed interface FetchError {
    data object FailedToConnectServer : FetchError
    data object FailedToValidateUser : FetchError
}

fun fetchUser(): Result<User, FetchError> { return TODO() }

fun main() {
    when (val result = fetchUser()) {
        is Result.Ok -> TODO() // 何らかの処理
        Result.Err(FetchError.FailedToConnectServer) -> TODO() // エラーひとつめ
        Result.Err(FetchError.FailedToValidateUser) -> TODO() // エラーふたつめ
        is Result.Err -> TODO() // それ以外
    }
}

一方でこの場合、網羅性チェックの恩恵を上手に受けることができなくなります。たとえば新しくエラーの種別を追加したとしても、is Result.Err側に全部吸収されてしまって、エラーのハンドリングの必要性に気づけません。Guard Condition(whenの腕の部分にifをつけるパターン)も同様で、網羅性チェックの恩恵を受けられなくなります。

// ...

sealed interface FetchError {
    data object FailedToConnectServer : FetchError
    data object FailedToValidateUser : FetchError
    data object FailedToInsufficientRole : FetchError // ←追加した
}

// ...

fun main() {
    when (val result = fetchUser()) {
        is Result.Ok -> TODO() // 何らかの処理
        Result.Err(FetchError.FailedToConnectServer) -> TODO()
        Result.Err(FetchError.FailedToValidateUser) -> TODO()
        // ここに本来さらに`FailedInsufficientRole`分を追加する必要があるが、コンパイルエラーにならず気付けないかも。
        // 下のis Result.Errに吸収されてしまっているため。elseにしても同じ。
        is Result.Err -> TODO()
    }
}

Result.Ok<T>であるとかResult.Err<E>のようなラッパー型(と発表内では言っていたのでそれに従う)を採用し、かつ網羅性チェックの恩恵を最大限受けようとすると、Kotlinの型システムの設計上は少々冗長な記述が必要になってしまうようです。かかる手間としては本当に微々たるもので、ここについてはさまざまな考え方があるとは思いました。が、Kotlinの言語設計者はこのワークアラウンドを避けたいと思ったようです。

細かい文法機能は後ほど説明しますが、エラーアトリビュートを採用するとこのwhenのネスト問題は起こらなくなるようです。次のように一段のwhenですべて解決可能になりそうです。これならば、エラー側に対する識別も、一段のwhenで済ませることができるようになります。

error object FailedToConnectServer

error object FailedToValidateUser

fun fetchUser(): User | FailedToConnectServer | FailedToValidateUser { ... }

fun main() {
    when (val result = fetchUser()) {
        is User -> TODO() // 何らかの処理
        is FailedToConnectServer -> TODO()
        is FailedToValidateUser -> TODO()
    }
}

次の論点として、エラーの伝播方法が考えられます。既存のkotlin-resultなどのResult型実装では、mapflatMapなどのコンビネータを使ってResult型をつなげるか、もしくはbindingというDSLを使って、そのDSLの中でResult型を使った処理を書くかのどちらかの記述方法になります。たとえば次のようにです。

sealed interface BindingError {
    data object ImportError : BindingError
    data object ValidationError : BindingError
}

fun readData(path: String): Result<ImportedData, ImportError> { ... }

fun validateData(data: ImportedData): Result<ValidatedData, ValidationError> { ... }

binding {
    val importData = readData().bind() // エラーが発生したらエラーを伝播
    val validatedData = validateData(importData).bind() // エラーが発生したらエラーを伝播
    return@binding validatedData // 最後までエラーが発生しなければここまで到達
}

mapflatMapを多用する書き方は型付けのデザインに慣れが必要なのと、既存のKotlinでのコードの書き方からは大きくずれるコーディングスタイルを使用する必要が出てきます。また、mapに渡す高階関数は実装にもよりますが同期関数であることが想定されるため、suspend funとの相性はよくないのではないかと思います。

bindingのようなDSLを使ってエラーを伝播させるパターンは、既存のKotlinの手続型的な書き心地を損なわずに、Result型の良さを享受できるよい方法だと考えています。kotlin-resultの作者的にはdo記法的な利用方法を想定していそうですが、実のところbindingのボディはただのスコープ関数なので、普通に手続型的なコードを記述できます。この記述方法は実質Rustの?演算子に近いもので、私としては一番理想的な方法だと思います。

bindingを利用する場合は新しい文法機能を言語処理系に導入しなくてもよく、標準ライブラリで提供するだけで良さそうです。そうなると先に紹介したResult型に対してスマートキャストをかけて中身を取り出す際のネスト問題だけが残ることになりますが、言語設計者的にはそちらの方は大きな問題に見えたのかもな、とは思いました。

最近多くのプログラミング言語でResult型のためのライブラリが導入されていますが、さまざまなケースを見ていると結構無理をして入れているなという印象を持ちます。Result型は型パズルもわりと生みやすく、複雑な型付けを嫌うKotlinとは相性が悪いというのはわかります。Result型はどんな言語に対しても万能というわけではなく、その辺りをきちんと考慮してエラーアトリビュートが導入されることになっていそうだなという印象を持ちました。

エラーハンドリングのデザイン哲学

次のようなデザイン哲学を元に設計を進めていると言われていました。

  • エラーは値である。特別なコントロールフローを作らない。
  • エラーは回復可能なケースのためにデザインされる。
  • 網羅性を大切にする。
  • chain-callを用いた短いコードを書きたいことはあるので、それは対応する。
  • 型システムを健全なままにしておく。

エラーはまず回復可能なケースのためにデザインされます。つまりそのエラーが出るとシステムが復旧不可能になるものは、引き続き例外を利用するということです。たとえばで例に出ていたrequireは、見方を変えれば既存の型を拡張するための構文です。requireで拡張された内容は、そもそも与えるデータを正しく整えないとそのデータを生成する条件を永久に満たせないことを意味するため、回復不能と言えるでしょう。こうしたケースでは引き続き、例外で済ませるのが良いと説明されていました。

エラーの伝播時に短いコードを書くだけで済むようにという考慮はされているようです。もともとボイラープレートを問題視して始まった設計なので、これは目的達成のために当然と言えるかもしれません。後述するようにいくつかのショートカット用の演算子が入るほか、細かいケースに対応するためのいくつかの専用関数が用意されて、ボイラープレートを削減できるようです。if err != nilのようにはならなそうで良いかもと思います。正直なところ、別に専用構文を導入したとしても、Kotlinの場合は裏で何段階かのIRがある関係で、どこかのタイミングで何かしらの代替処理に脱糖をかければよいだけでしょう。ただの予想ですが、この辺りの事情もあるかもなと思いました。

型システムを健全なままにしておくというのは要するに、たとえばモナドを統一的に扱えるように高階カインド型を入れてしまうと、その分だけ型システムが複雑化してしまうが、そうはしたくないというような意味でしょう。結果的にコンパイルスピードも遅くなりますし、そうした複雑な型付けには慎重な立場のようです。

具体的にどのような機能が実装されるか

  • T | Error
  • errorキーワードの導入
  • in-place tags
  • safe-call
  • Exceptionとの使い分け

T | Error

見た目はほとんどユニオン型ではありますが、新たにT | Errorという型の記法が導入されるようです。これによりエラーを値として扱うことができるようになります。この型はis Tでスマートキャストすると以降の処理フローではT型になり、逆にis Errorでスマートキャストすると以降の処理フローではError型になります。

T側をMain Actorと呼ぶようです。このMain Actorはひとつしか持たせることはできません。つまり、ABという型があったとして、それぞれをMain Actorとして仮定したとすると、A | B | Errorのような記法はないということです。Kotlinでは現状ユニオン型はまったく導入されておらず、Main Actor側のユニオン型はまずそもそもサポートされていないからといったところでしょうか。

Error側はError Attributes(「エラーアトリビュート」)と呼ぶようです。こちらは複数個持たせることができます。エラー側は実質ユニオン型になっているという理解でよさそうに思いました。T | Error | AnotherErrorという書き方がたとえばできるということです。エラーは複数種類返される可能性があるため、それを考慮した型付けになったのだと思いました。

Error側には、後述するerror classerror object、つまりError型のみを置くことができます。この原則に従うと、現状ではInt | Stringのような記述はできません。

errorキーワードの導入

errorというキーワードが新しく導入されます。現状の構想では、error classerror objectという2つの新たな構文が利用できるようです。それぞれは、既存のclassobjectと同じ文法機能を持ちます。

errorというキーワードがつけられたclassないしはobjectの型は、Errorという型のサブタイプということになります。Errorは、今回のデザインでは新しく導入されることが検討されている型のようです。スマートキャストの際も、is Errorとすることで以降の処理はエラー側の型であるとキャストできるようになります。

新しいKotlinの型階層は次のようになる予定です。Nothingをボトム型とすることは変わりませんが、トップ型がAny?だけだったものから、Any? | Errorへと変わります。型階層が分かれることに加え、errorで宣言されるclassないしはobjectはすべてfinalとしてマークされます。Rich Errorsは型階層上分離された存在になります。これらのおかげで、Main ActorとError Attributesは非交和として扱うことができるようになります。イメージがつきやすくなるよう、発表資料にあった型階層を下記に引用しておきます。

Kotlinの新しい型の階層。`Any?`と同位置に`Error`が入る。これは実質、`Any`側の階層には`Error`は入らず、独立した存在であることを示唆している。

in-place tags

変数宣言時にT | Errorの型注釈を記述できるようになります。今回のRich Errors専用の型注釈として導入されるという理解をしました。このように直接記述できないと、まず型推論がうまくいかなかった際の対処のしようがなくなるので、必要な機能だと思いました。あるいは余計なasを使ったキャストを避けられるメリットもあります。

Safe Call

nullableに対して呼び出すことができた?.演算子をRich Errorsでも利用することができます。expr1?.expr2?.expr3と記述した場合、たとえばexpr1でエラーが発生した場合、expr3までそのエラーを伝播させ、最後にその式の返す値はエラーにすることができます。ボイラープレートの抑止に効果的だと思います。

また、nullableに対して!!演算子を使用すると、中身がnullでなければその型をnon-nullableな型にし、そうでない場合はNullPointerExceptionを送出させることができます。同様にRich Errorsに対して!!演算子を使用すると、仮にその式の評価結果がエラーだった場合、KotlinExceptionという例外を送出させることができます。

ただし、エルビス演算子 ?: は提供されません。エルビス演算子をRich Errorsに対して使用した場合、何かしらのエラーオブジェクトを返す必要があります。たとえば下記のコードのようにです。

expr ?: return ElvisError

発表ではエルビス演算子をRich Errorsには導入しない理由として、「この方式だとユーザーがエラーハンドリングを忘れることになりそう」と言っていましたが、どういうことなんだろうとは思いました。

私が思ったのは、nullableな型を返す(T? つまり T | null)関数等に対してエルビス演算子を適用した場合、Tnullかの分岐しか発生しません。つまり、?:以降に入るのは、そもそもnullであるときだけです。しかしRich Errorsの場合、エラーアトリビュートの型は複数種類指定可能です。この場合、どうやってエルビス演算子ですべてのエラーを「網羅的に」ハンドルしたら良いでしょうか?Rich Errorsの元々のデザイン哲学に従うのが難しくなるでしょう。このことから、エルビス演算子の使用は原理的にそもそも難しいと言えるのではないかと思いました。

回避策として、Kotlinでは拡張関数を用意すればよいだけではと提案されていました。発表資料からそのまま引用すると、ifErrorという拡張関数を用意すればよいのではないか、ということです。

inline fun <T, E : Error> (T | E).ifError(default: (E) -> T): T {
    return if (this is Error) default(this) else this
}

val result = expr.ifError { return }
expr.ifError { e ->
    when (e) {
        is ParsingError -> ...
        ...
    }
}

Exceptionとの使い分け

先ほども説明した通り、回復可能なケースにのみエラーを使用し、回復不能なケースでは例外を使用するという使い分けになるようです。既存のサードパーティライブラリ等が例外を投げるケースを考慮するためには、Result<T>型でも用意されていたrunCatchingのような関数を用意する必要があり、それは検討されているようです。

ちなみにですが、runCatching関数はThrowableをなぜかキャッチするため、supend funなどではCancellationExceptionを掴んでしまって最悪な体験になる(もはやバグ)ことがありました。この点はRich Errorsでは考慮に入れられている旨の話が発表中にはありました。よかったです。

感想や考察

ユニオン型とは記法を分けて欲しい

ひとまず、「エラーアトリビュート」という新しい概念であって、ユニオン型ではないので記法を分けて欲しいと思いました。たとえばT ! Error | AnotherErrorとかですかね?エラー側はユニオン型であってもよいものの、TErrorは同じ型の分類ではない(型階層的にも、AnyErrorとで先が違う)ので分けるのが筋かなと思いました。Stringをエラー型にすることは簡略のためにままあるんですが、Int | Stringはできません。直感的ではないと思いました。最初発表動画を見たとき単なるユニオン型かと思いましたが、もう一度動画を見たら違うことをようやく理解できました。

エラー側の記述について

たとえばひとつの関数がエラーを5種類返します、となった場合どうなるでしょうか?T | ErrorOne | ErrorTwo | ErrorThree | ErrorFour | ErrorFiveとなりそうですね。長くて読めたものではありません。場合によっては正常側とエラー側との境目の識別が難しくなったりするかもしれません。

この大変さを回避するための策として、typealiasの使用が発表内では例示されていました。具体的には下記のようにすればよいということです。

error object FailedToConnectServer

error object FailedToValidateUser

typealias FetchError = FailedToConnectServer | FailedToValidateUser

fun fetchUser(): User | FetchError { ... }

fun main() {
    when (val result = fetchUser()) {
        is User -> TODO() // 何らかの処理
        is FailedToConnectServer -> TODO()
        is FailedToValidateUser -> TODO()
    }
}

ただtypealiasを毎回定義するのもな、と思ったので、ぜひerror enum classあたりを利用可能にしてもらいたいなとは思いました。次のように利用できると実務上は嬉しそうだと思われます。[*1]

error enum class FetchError {
    FailedToConnectServer,
    FailedToValidateUser
}

fun fetchUser(): User | FetchError { ... }

fun main() {
    when (val result = fetchUser()) {
        is User -> TODO() // 何らかの処理
        is FetchError.FailedToConnectServer -> TODO()
        is FetchError.FailedToValidateUser -> TODO()
    }
}

あとは、エラーを階層表現したい場合にどうしたらよいのだろうとは思いました。Kotlinの場合、whenにネストが発生しやすい関係でエラーを階層構造化するのがそもそも若干悪手かもしれませんが、実務上は結構見るように思われます。これもerror enum classさえあればと行きたいところではあるんですが、KotlinのenumJavaenumと同義なので、表現力が非常に乏しいです。つまりやはり、sealed interfaceあたりを使用可能にしてもらうのが最もよいなあ…という感想です。[*2]

error sealed interface FetchError {
    error object FailedToConnectServer : FetchError
    error class FailedToValidateUser(uuid: UUID) : FetchError
}

いわゆるアーリーリターン問題

エルビス演算子が提供されないとなるとアーリーリターンを簡潔に記述するのが難しくなりそうです。エルビス演算子の箇所の説明であった、ユーザーがエラーハンドリングを忘れるという話がいまいちどういうユースケースがあるのか理解できていませんが、次のような記法を使って回避したりはできないのかなと思いました。

// こう書くと、exprがエラーだった場合、そのまま呼び出し元にエラーを伝播する
val result = expr ?: return

ただこうしてしまうと、nullableの場合はこの記述はただのユニット型のreturnを意味しているのに対し、Rich Errorsの場合はアーリーリターンになるという奇妙なチグハグが生まれます。まあ、微妙かもしれませんね。ただ、アーリーリターンは入っていてほしいなとは思いました。if expr is Error { return expr }とおとなしく書くか、ifErrors関数を使用することになるんでしょうか。

?.?.問題

他に起こりそうだなと思ったのは、nullable?.とRich Errorsの?.が混在するパターンでしょうか。GitHub上などでコードレビューする際に、一体何をハンドルしているのか区別が難しくなりそうだなと思いました。

fun function(): SomeType? | Error
val res = function?.?.value

上記の例では、ひとつ目の?.でエラーでないかどうかをチェックし、二つ目の?.でnullでないかどうかをチェックします。IDEなどコードジャンプをして実装状況を確認できる環境であればさほど問題になりませんが、GitHub上でのコードレビュー時などには判別が難しくなるのではないかとは思いました。

ちなみにですが、Rustでもままepxr??のような書かれ方をすることがあります。Result<Result<T, E>, E>になってしまった際にこれが起こります。読み解く際にそこまで困ったことはないですが、気持ち悪いという気持ちもちょっとわかりますね。

まとめ

Rich Errorsという新しいKotlinのエラーハンドリングに関する機能の設計情報をまとめました。Rich Errorsの中心にあるのはエラーアトリビュートという機能で、この機能では新たにerrorというキーワードが導入されます。また、型システム上にも少し変更が入り、Errorという型が新たに導入されることになります。この型はAnyの型階層には影響を与えない独立した位置に導入されます。これによりKotlinのエラーハンドリングはユニオン型とスマートキャストの恩恵を上手に受ける形にまとまっていると言えるでしょう。

*1:ちなみにですが、enumのヴァリアントの命名規則は実はUpper Camel Caseでも構わないようです。私はScreaming Snake Caseよりこちらが好きなのでこの命名規則を採用しています。 https://kotlinlang.org/docs/coding-conventions.html#property-names

*2:error sealed interfaceがなぜ実現できないのかはとくに発表中に言及もなかったのでわかりませんでした。Error型の継承先は、新しく用意される型システムの都合からfinalでなければならないので継承不可能ですが、interfaceの場合final interfaceというものはないので、実現自体は可能なはずです。