🐕

RustのResultをTypescriptでも導入したい!

2023/07/14に公開

導入

例外処理の話をするのには理由があります。

大学生になって、始めて触った言語はC++でした。今では考えられないほど使いにくかったMicrosoft Visual Studio2008と格闘しながら、書籍「ロベールのC++」のコードを写経していました。(驚くなかれ、ロベールのC++は1000ページあります。先にも後にもこれ以上長い本を読んだことはありません)

当時はコンパイルエラーを直すのに精一杯で、(コード補完が無かった気がする)実行時エラーを気にする余裕はありませんでした。エンジョイするはずだった大学生最初の夏休みを潰しました。

その後しばらくプログラミングからは離れてしまいましたが、修士1年生の時にインターンシップ先に選んだ会社でJavaと出会います。
C++のメモリリークに悩まされた経験から、メモリ管理が必要ないJavaはとても使いやすかったのを覚えています。すぐに簡単なゲームを作れるようになりました。

次第に、複雑なアプリケーションを作るようになると、実行時エラーをどう扱うかについて悩むようになりました。その場しのぎでエラーをthrowして、呼び出し元に処理をさせることを繰り返すと、次第にコードが手に負えなくなりました。今では実行時エラーと例外について当時より詳しくなり、マシなコードを書いていますが、設計の難しさは変わりません。

例外処理の方法

Try&catch構文はC系の言語でサポートされ、Javascriptにも取り入れられました。正常系とそれ以外(今後は異常系と呼びます)をコードブロックで分けることで、見た目にも分かりやすく、コードを書く人にとっては、正常系の(想定される)動作のプログラミングに集中できるメリットがあります。

デメリットは、エラーや例外処理が後回しになることです。誰でも必ず、キャッチした例外をそのまま投げているコードを保守した経験はあるでしょう。コードが大規模になれば、try&catch地獄が待っています!その頃には、どんな例外がどこから投げられているか分からない、クソコードになっているはずです。例外処理に関しては、怠惰さ=美徳とは言えません。

最近のプログラミング言語には、try&catch構文はあまり取り入れられてません。
Golangの例を見てみましょう。正常系と例外を同じブロックで扱えるために、Golangでは二値(多値)returnを取り入れています。次のコードはDBからユーザ情報を取得する例です。

type User struct{
  Name string `json:"name"`
}

func FindUser(id string) (User,error){
  var user User
  //db setup 
  if err := db.QueryRaw(`SELECT * FROM users WHERE id = ?`,id).Scan(&user.Name);err!=nil{
    return err
  }
  return user
}

func SignIn(id string){
  user, err :=FindUser(id)
  if err !=nil{
    //error handling    
  }else{
    //do something..
  }
}

例外処理は、golangのシンプルさが際立っていますね。Try&catchよりも見やすいです。ただし例外処理が複数ある場合は、if文が乱立して処理が追いにくくなるでしょう。

Typescriptでも似たようなことができます。多値返却はサポートされていませんが、union型を使えばgolangと同じことができるでしょう。以下はそのままtypescriptに置き換えたものです。

  type User = {name:string}
  type UserResult = User|{error:Error}
  
  function findUser(id:string): UserResult{
    //db connection
    const user = db.query(`SELECT * FROM users WHERE id = ${id}`)
    if (!user){
      return {error:new Error("no user")}
    }else{
      return {name:user.name}
    }
  }

  function isUser(result:UserResult): result is User{
   return !(`name` in result)
  }

  function signIn(id:string){
    const result = findUser(id)
    if(!isUser(result)){
      //error handling
    }else{
      //
    }
  }

If文による例外処理は、Try&catchよりも分かりやすいでしょうか?エラー処理として優れているのはどちらでしょうか?
私が考える、If文のメリットとデメリットは以下の通りです。

メリット

  • 正常系と異常系の距離が近くなり、何をやっているか(どのエラー処理をしているか)分かりやすい
  • 異常系を先に処理してreturnすることで、正常系の処理に集中できる

デメリット

  • 異常系の処理が1つのブロックで扱えない

処理が目で負いやすい半面、処理自体が複雑になる傾向にあります。確かに私が保守しているgoのコードは、基本if文の連続です。goらしいといえばそれまでなんですが、決して見やすいとは言えません。より改善できる案はないでしょうか?

Result型の例外処理とは?

Try&Catchによる従来の例外処理と、If文を使った例外処理よりも優れた方法はあるでしょうか?

今までの論点をまとめると、

  • エラー処理をなるべくまとめて扱いつつ
  • 正常系と異常系のエラーのコードブロックを分けない
  • try-catchやif elseのようにネストになりにくい

の3点を満たしたエラー処理を一緒に考えていきましょう。ここでヒントになるのはfilter関数です。

  [...].filter((item)=>..).map(/**handling */)

フィルター関数が優れている点は、正常系の処理が透過的に行えることです。
やっていることはif文と変わりませんが、ネストもないですし、同じコードブロックで処理できてます。
結果をフィルターしてるだけで例外処理はできていませんが、スタート地点には良いでしょう。

例外処理をするには、フィルター関数を拡張したfilter_or_elseを考えます。下のような感じです。

const [results,errors] = [...].filter_or_else(item=>).map(/**handling  */)
if (errors.length !==0) {
  //Error handling
}

正常系だけでなく異常系も返すため、例外処理が行えるようになりました。
ただしif文が残っているので、以下のように修正します。

const  [results,errors] = [...].filter_or_else(item=>).mapOk(/**handling */).mapError(/**Error handling */)

結構良い感じのインターフェースになりました!

ただし入力値と返り値の型が違っていますね。これだとfilter関数のように.filter().filter()..と重ねて使えません。
改善しましょう。もう少しの辛抱です。

type Result<ERR,OK> = Err | OK
const results: Result<Error,Ok>[]
const results_1 = results.filter_result(result=>).mapOk(/** */).mapError(/**handling */)
const results_2 = results_1.filter_result(result=>).mapOk(/** */).mapError(/**handling */)

入力値と戻り値を同じ型にしました。これで、filter関数のように透過的に使えます。
後は、リスト以外にも使えるように修正するだけです。filter_resultという名前も、リストを前提としているので取り除いてしまいましょう。結果はこうなります。


class Result<ERR,OK>{

  //instance are immutable
  readonly value: {error:ERR} | {ok:OK}

  //apply fn only when value is OK
  mapOk<T>(fn:(value:OK)=>T):Result<ERR,T>{
    if ('ok' in this.value){
      return  Result.Ok(fn(this.value.ok))
    }else{
      return Result.Err(this.value.error)
    } 
  }

  //apply fn only when value is ERR
  mapError<F>(fn:(value:ERR)=>F):Result<F,OK>{
    if (`ok` in this.value){
      return Result.Ok(this.value.ok)
    }else{
      return Result.Err(fn(this.value.error))
    }
  }

  //OK and ERR can be same type, so which argument defines type of value
  //and should be private constructor
  private constructor(value:OK|ERR,which:boolean){
    if(which){
      this.value = {ok:value as OK}
    }else{
      this.value = {error:value as ERR}
    }
  }
  
  //use like Result.Ok<string,User>({name:"mori"})
  static Ok<Err,OK>(value:OK){
    return new Result<Err,OK>(value,true)
  }
  //use like Result.Err<string,User>('no user') 
  static Err<Err,OK>(value:Err){
    return new Result<Err,OK>(value,false)
  }
}

ちょっと長くなりましたが、各行にコメントを書いたので意味は分かると思います。
リスト以外にも扱えるように、ジェネリックを使っています。これを使って先ほどのログイン処理を書いてみましょう。

 type User = {name:string}
 findUser(id:string):Result<Error,User>{
    const user = db.query(`SELECT * FROM users WHERE id = ${id}`)
    if (!user) {
      return Result.Err<Error,User>(new Error("no user"));
    } else {
      return Result.Ok<Error,User>({name:user.name})
    }
 }

 function signIn(id:string){
    const result = findUser(id)
    result.mapError(err=>{
      //error handling
    }).mapOk(user=>{
      //something like session update
    })
  }

先ほどのisUser関数が不要になり、findUserの返り値もシンプルに処理できました。Result型を受け取りResult型を返す関数を繋ぎ合わせることで、透過的にエラーを扱えるようにもなりました。

さらには、先ほど上げたエラー処理の3つの論点

  • エラー処理をなるべくまとめて扱いつつ
  • 正常系と異常系のエラーのコードブロックを分けない
  • try-catchやif elseのようにネストになりにくい

もそれぞれ達成されています。すばらしいですね。

Result型はRustやhaskellなどの関数型言語の標準ライブラリで入っていますが、typescriptでは残念ながら入っていません。ミニマムなResult型でも十分パワフルですが、私のプロジェクトでは以下のようにResult型を定義しています。コードは長くなりますが、コピーアンドペーストで使えるので良かったら参考にしてみてください。

export type Result<T, F> = Success<T, F> | Failure<T, F>;

interface IResult<T, F> {
    isSuccess(): this is Success<T, F>;
    isFailure(): this is Failure<T, F>;
    readonly value: T | F;
    map<R, L>(fnL: (f: F) => L, fnR: (t: T) => R): Result<R, L>;
    mapR<R>(r: (t: T) => R): Result<R, F>;
    mapL<L>(l: (f: F) => L): Result<T, L>;

    fmap<R, L>(fnL: (f: F) => Result<R, L>, fnR: (t: T) => Result<R, L>): Result<R, L>;
    fmapR<R>(fnR: (t: T) => Result<R, F>): Result<R, F>;
    fmapL<L>(fnL: (t: F) => Result<T, L>): Result<T, L>;
}

export class Success<T, F> implements IResult<T, F> {
    constructor(value: T) {
        this.value = value;
    }

    readonly value: T;

    isSuccess() {
        return true;
    }

    isFailure() {
        return false;
    }

    fmap<R, L>(fnL: (f: F) => Result<R, L>, fnR: (t: T) => Result<R, L>): Result<R, L> {
        return fnR(this.value);
    }

    fmapR<R>(fnR: (t: T) => Result<R, F>): Result<R, F> {
        return fnR(this.value);
    }

    fmapL<L>(fnL: (t: F) => Result<T, L>): Result<T, L> {
        return success<T, L>(this.value);
    }

    map<R, L>(fnL: (f: F) => L, fnR: (t: T) => R): Result<R, L> {
        return success<R, L>(fnR(this.value));
    }

    mapR<R>(fnR: (t: T) => R): Result<R, F> {
        return success<R, F>(fnR(this.value));
    }

    mapL<L>(_: (f: F) => L): Result<T, L> {
        return success<T, L>(this.value);
    }
}

export class Failure<T, F> implements IResult<T, F> {
    constructor(value: F) {
        this.value = value;
    }

    readonly value: F;

    isSuccess() {
        return false;
    }

    isFailure() {
        return true;
    }

    fmap<R, L>(fnL: (f: F) => Result<R, L>, fnR: (t: T) => Result<R, L>): Result<R, L> {
        return fnL(this.value);
    }

    fmapR<R>(fnR: (t: T) => Result<R, F>): Result<R, F> {
        return failure(this.value);
    }

    fmapL<L>(fnL: (t: F) => Result<T, L>): Result<T, L> {
        return fnL(this.value);
    }

    map<R, L>(fnL: (f: F) => L, fnR: (t: T) => R): Result<R, L> {
        return failure<R, L>(fnL(this.value));
    }

    mapR<R>(_: (t: T) => R): Result<R, F> {
        return failure<R, F>(this.value);
    }

    mapL<L>(fnL: (f: F) => L): Result<T, L> {
        return failure<T, L>(fnL(this.value));
    }
}

export function success<T, F>(value: T) {
    return new Success<T, F>(value);
}

export function failure<T, F>(value: F) {
    return new Failure<T, F>(value);
}

使い方は先ほどと殆ど同じです。

 type User = {name:string}

 findUser(id:string):Result<User,Error>{
    const user = db.query(`SELECT * FROM users WHERE id = ${id}`)
    if (!user) {
      return failure(new Error("no user"));
    } else {
      return success({name:user.name})
    }
 }

 function signIn(id:string){
    const result = findUser(id)
    result.map(user=>{
      //something like session update
    },err=>{
      //error handling
    })
 }

それでは、よいコーディングライフを!

Discussion