話題の記事

TypeScriptの異常系表現のいい感じの落とし所

みなさんTypeScriptでサーバアプリケーション(Node.js)のロジックを書く時に、異常系の表現をどのようにされていますでしょうか?ここでいう異常系とは、仕様上想定される異常のことです。準正常系と言ったりもするかと思います。

私はJavaScriptの延長でTypeScriptをはじめたので、最初は nullundefined を返したり throw を用いるやり方をしていましたが、次第にTypeScriptが持つ型を生かし、できるだけ型安全に異常系を表現したいと考えるようになりました。そして試行錯誤した結果、いい感じの落とし所に落ち着いたので、その内容についてお伝えしたいと思います。

また記事の後半では、異常系の型を実装する中でハマった点についてもお伝えしたいと思います。

TypeScriptの異常系表現について

1. nullやundefinedを返す

冒頭でも述べたように、最初は異常系の表現に nullundefined を利用していました。つまり、関数の戻り値の型に Union Type を用いて string | null のように表現するということです。

// 何らかの処理を行う関数が存在する
function doSomething (x: boolean): string | null {
  if (x) {
    return 'success'
  } else {
    return null
  }
}

const result = doSomething(true)

if (!result) {
  // 異常系の処理...
  return
}

// 正常系の処理...
// この時点でresultの型は string に限定される

一見これでも良さそうに見えますが、以下のような問題があります。

  • 複数の異常系を表現できない
  • null が正常系の一部なのか、異常系の位置づけなのかの意図が伝わらない

2. Errorをthrowする

そこでエラーの型を作って throw で投げるようにしてみます。

class DoSomethingError extends Error {}

// 何らかの処理を行う関数が存在する
function doSomething(x: boolean): string {
  if (x) {
    return 'success'
  } else {
    throw new DoSomethingError()
  }
}

let result: ReturnType<typeof doSomething>

try {
  result = doSomething(true)
} catch (e) {
  if (e instanceof DoSomethingError) {
    // 異常系の処理...
    return
  } else {
    // 想定外の異常は呼び出し元にthrowする
    throw e
  }
}

// 正常系の処理...

このように try ~ catch を用いることで、呼び出し元に異常の詳細を伝えたり、2つ以上の異常系を表現することができます。しかしこの方法では、呼び出した関数がどのようなエラーの型をthrowするかが型で表現されていませんので、呼び出し元でハンドリングが行われずバグの原因になる可能性があります。Javaのthrows節のように、throwされる型を定義できればよいのですが。

3. Errorのオブジェクトを返す

そこで throw は使わずに、関数の戻り値に Union Type を使ってエラーのオブジェクトを返すようにします。

class DoSomethingError extends Error {}

// 何らかの処理を行う関数が存在する
function doSomething(x: boolean): string | DoSomethingError {
  if (x) {
    return 'success'
  } else {
    return new DoSomethingError()
  }
}

const result = doSomething(true)

if (e instanceof DoSomethingError) {
  // 異常系の処理...
  return
}

// 正常系の処理...
// この時点でresultの型は string に限定される

これで関数の異常系を型安全に表現することができました。

でもまだ満足できない点がいくつかあります。

  • 「Errorオブジェクトを返しましょう」という規則を作っただけなので、プロジェクトで一貫した書き方を強制する力が弱い。
  • すべての異常系においてErrorの型を書くのがだるくなってくる。
  • より抽象的な型の表現を用いたい。

これらを解決しようとすると、Scalaの Either のような関数型プログラミングのアプローチを取り入れたくなってきます。実際にそのようなことを実現するライブラリとして neverthrowfp-ts などが見つかりました。これらのライブラリを用いて関数型プログラミングのアプローチを使うことは1つの手段としてありですが、ライブラリへの依存度が高くプロジェクト全体への影響も大きいため見送りました。

4. 戻り値を抽象型を返す

そこで、できるだけ薄く型安全な戻り値を表現するために、Resultという抽象型をだけを作りました。Scalaの Either のような、正常系と異常系のいずれか一方を持つ型です。この実装にはまだ問題がありますが、それは後半で説明します。

type Result<T, E> = Success<T, E> | Failure<T, E>

class Success<T, E> {
  constructor(readonly value: T) {}
  isSuccess(): this is Success<T, E> {
    return true
  }
  isFailure(): this is Failure<T, E> {
    return false
  }
}

class Failure<T, E> {
  constructor(readonly value: E) {}
  isSuccess(): this is Success<T, E> {
    return false
  }
  isFailure(): this is Failure<T, E> {
    return true
  }
}

このResult型を使うと、ロジックを以下のように書くことができます。

class DoSomethingError extends Error {}

// 何らかの処理を行う関数が存在する
function doSomething(x: boolean): Result<string, DoSomethingError> {
  if (x) {
    return new Success('success')
  } else {
    return new Failure(new DoSomethingError())
  }
}

const result = doSomething(true)

if (result.isFailure()) {
  // 異常系の処理... 
  // さらに場合分けする場合は result.value instanceof SomeError などで判断する
  return
}

// 正常系の処理...
// この時点でresultはSuccess型と限定され、valueはstring型に限定される

これにより、関数の戻り値の意図を正確に伝えられるようになりました。また、関数の呼び出し元に異常系のハンドリングを強制させることができます。異常系の型は何でも良いので、例えば異常を無視させて良い場合は、 Result のようにすることもできます。

Result型の問題点

TypeScriptは構造で型を判定する

ところで、最後に紹介した Result ですが、実際に実装コードを書いてみると、実は以下のような問題が起きます。

class SomeResult {}

// 何らかの処理を行う関数が存在する
function doSomething(x: boolean): Result<SomeResult, unknown> {
  if (x) {
    return new SomeResult()
  } else {
    return {} as unknown
  }
}

const result = doSomething(true)

if (result.isFailure()) {
  // 異常系の処理... 
  // resultがResult<SomeResult, unknown>と推論される -- ここが問題箇所
  return
}

result.isFailure() で、 User-Defined Type Guards を用いて resultFailure であると定義したにもかかわらず、 Result と推論されます。

実はTypeScriptは、型を判断する時に、プリミティブな型以外はオブジェクトの構造で型を判断しています。これがTypeScriptが構造的型付けと言われるものです。構造とは、オブジェクトが持つプロパティの型とメンバ関数の型(シグネチャ)をあわせたものです。

前の例では、オブジェクトの構造は以下のようになっています。

// Failure<SomeResult, unknown>の構造
{
  value: unknown,
  isSuccess(): this is Success<T, E>,
  isFailure(): this is Failure<T, E>,
}
// Success<SomeResult, unknown>の構造
{
  value: SomeResult,
  isSuccess(): this is Success<T, E>,
  isFailure(): this is Failure<T, E>,
}

unknown という型は全ての型のスーパータイプであるため、

const value: unknown = new SomeResult()

が成立します。

value以外の構造はすべて同じなので、

const result: Failure<SomeResult, unknown> = new Success<SomeResult, unknown>(new SomeResult())

も成立します。

なのでこの場合は Failure = Failure | Success となり、 Result と推論されたということです。

これを防ぐには構造的な違いが必要なので、以下のようにすると解決できます。(これが最終形です。)

type Result<T, E> = Success<T, E> | Failure<T, E>

class Success<T, E> {
  constructor(readonly value: T) {}
  type = 'success' as const // ここを追加
  isSuccess(): this is Success<T, E> {
    return true
  }
  isFailure(): this is Failure<T, E> {
    return false
  }
}

class Failure<T, E> {
  constructor(readonly value: E) {}
  type = 'failure' as const // ここを追加
  isSuccess(): this is Success<T, E> {
    return false
  }
  isFailure(): this is Failure<T, E> {
    return true
  }
}

TypeScriptのクラス定義は、コンストラクタとインスタンスに分かれる

ところで、SuccessとFailureのクラス定義にはコンストラクタのシグネチャに違いがありますが、オブジェクトの構造には現れてきません。これはどういうことなのかと疑問に思いました。

実はTypeScriptでクラス定義をしたときには、コンストラクタタイプとインスタンスタイプの2つが定義されます。

コンストラクタタイプはコンストラクタ関数や静的メンバ・プロパティの定義です。このコンストラクタ関数によって返されるインスタンスの型がインスタンスタイプ(インスタンスのメンバ変数とプロパティ)となります。

コード上では、インスタンスタイプを クラス名 でアクセスするのに対し、 コンストラクタタイプが typeof クラス名 でアクセスすることができます。

おわりに

異常系に表現については書籍プログラミングTypeScriptでもだいたい同じことが書かれていました。最終的なアプローチとしてOptionalを定義してflatMapを定義して関数型プログラミングのアプローチを例に示していました。興味があればこちらも読んでみると良いと思います。

構造的型付けについては知識として知っていたものの、今回の問題ではUser-Defined Type Guardsを使っていたのでそれに気づくことができず、Stack Overflowで質問をして教えてもらいました。

typescript - User-Defined Type Guard with Generics may not work - Stack Overflow

メタプログラミングRubyを読んで、class Classを理解したときと同じ感触をひさびさに味わいました。

参考