🐷

TypeScript エラーハンドリングにResult型は要らない?

2022/11/10に公開
1

TypeScriptコンパイラの力でResult型のようなエラーハンドリングをもっとシンプルにできるのではという話です。

具体的には、下記のようにメソッドの戻り値をif分で絞り込むだけのコードになります。
Result<T>型を定義したり、isOk メソッドなんかで絞り込みはしません。


const { user, error } = createUesr()

if(error) {
  // user は undefined、 error は CreateUserErrorに型が絞り込まれている。
} else {
  // user は User、 error は undefinedに型が絞り込まれている。
}

結局Result型は定義するんですけどね

try/catchではなくResult型

エラーハンドリングの戦略として、try/catchの他にResult型を使うアプローチがあると思います。

個人的にも想定されるエラーをError型として定義し、throwすることなくメソッドの戻り値として、返す実装を好んでいます。

しかしTypeScriptには組み込みのResult型はないため、自作のResult型(クラス)を定義したり、場合によってはそれだけのためにライブラリの導入を検討しなければなりません。

(この記事公開時点)でTypeScript Resultで調べると上記のようなアプリーチが多数ヒットしますが、私としてはアプリケーション全体をそれらに依存させることに抵抗感を感じておりました。

しかし、最近(といってももう半年以上前)のバージョンアップで、いい感じに書けるようになってたことに気づきました。

実装例

例えば、ユーザーを作成するcreateUser関数を考えてみます。

// Userのイメージ
type User = {
  id: number;
  name: string;
  email: string;
}

ユーザーの作成には、あらかじめ想定されるエラーケースがあり、それらを以下のようにユニオンタイプで定義します。

type CreateUserError =
  | {
      // フォーマットや重複等のvalidationエラーを想定
      type: "validation";
      email?: string;
    }
  // 無料枠でユーザーの作成数に上限があるケースを想定
  | { type: "upperLimit" };

そして以下のようなCreateUserResultResult型を定義して、createUserメソッドを定義してみます。 今回は一応動くコードとしてuserをそのまま返すような実装にしてみました。

type CreateUserResult = {
  user: User,
  error?: undefined;
} | {
  user?: undefined;
  error: CreateUserError
}

function createUser(): CreateUserResult {
  return {
    user: {
      id: 1,
      name: 'taro'
      email: 'tarochan@example.com' 
    }
  }
}

メソッドの戻り値は下記のようにそれぞれ展開でき、if分で型の絞り込みが可能です。

const { user, error } = createUesr()

if(error) {
  // user は undefined、 error は CreateUserErrorに型が絞り込まれている。
} else {
  // user は User、 error は undefinedに型が絞り込まれている。
}

自前のResultクラスやisSuccess()メソッド等での型の絞り込みが不要で非常にスッキリしています。追加したコードも型の宣言のみで実際のコードへの追加はありません。

デメリットとして少し型の定義が冗長になるのですが、十分許容できる範囲だと思います。

気になる方はResult<User, CreateUserError> のようにいい感じのUtilty型の生成にチャレンジしてもいいかもしれません。

とにかく、ごく自然に型安全にエラーハンドリングができるような気がします。

例えばこんな感じ??

type Result<T, E> = (T & { [P in keyof E]?: undefined }) | ({ [K in keyof T]?: undefined } & E);

type CreateUserResult = Result<{ user: User }, { error: CreateUserError }>;

おわりに

https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-6.html#control-flow-analysis-for-destructured-discriminated-unions

上記変更で可能になったものだと思います。

当時は全然気づかなかったのですが、これで何の躊躇いもなくResult型のようなエラーハンドリングを快適に行うことができそうです。

ただ、上記の変更で可能になっただけであり、実際にはsuccess等のフィールドを定義して絞り込みを行った方が想定されている使い方なのかもしれません。

最後にサンプルコードををPlaygroundで公開しておきます。

https://www.typescriptlang.org/play?ts=4.6.4#code/C4TwDgpgBAqgzhATlAvFA3gKCjqBLAEwC4oA7AVwFsAjJAbm11IENKIS5hE9SBzB3FAiVmeADYcuPfpgC+mTKEhQAwogjNgEeEgCiiRAHtkaTFhxL2UAOQA3ZmMKa8h0tYA0jYaLEB+Sdx8DLJQAD4YjJYk1uRgkIgAMniUeMDWcgqWquqa2giIAEoQcORiwKhQZozk+SQ6iAJCBsb+UOSkBBAAZjwQBHJhETg1SK3tnT2kfY1IRogkahpa9fpzGZhd7QDGwC6kUFs5y-kAFACUC0d5SEUlZUNQAPSPUHCsYGIQjOrA5Ij75lwI3mD0EhBIAEZPIIcCw2NFgMwjB5GIJvOIEUjDFsABbMUgAAQgAA93p8AHRbQyUdKCeQ4eTyBRU0icDBtfLuJpzKAhNCHJbXRDnBR4LonYFnB4stnkOr5CrAxoy8pWcbdXoECqzYzK1xwQwUsSGXgS9wQM4DCBiBDS-XlOVtDoaqZatDAxgqoSXQUrZombnGT36w0QcnG03kc2W+RAA

上記コードはversion 4.6.4のものですが、それ以前のバージョンだとエラーになるのがわかると思います。

Discussion

nap5nap5

気になる方はResult<User, CreateUser> のようにいい感じのUtilty型の生成にチャレンジしてもいいかもしれません...

neverthrowから提供されているResult型などを使って実装にチャレンジしてみました。

async function requestToBFF(): Promise<ServerSideEnvData> {
  const response: AxiosResponse<ServerSideEnvData, ErrorData> = await axios.get(
    '/api/ping'
  )
  const { data } = response
  return data
}

export class ServerSideEnvRepository implements ServerSideEnvFactory {
  async ping(): Promise<Result<ServerSideEnvData, ErrorData>> {
    return ResultAsync.fromPromise<ServerSideEnvData, ErrorData>(
      requestToBFF(),
      (e) => e as ErrorData
    ).map((value) => value)
  }
}

デモコードです。

https://codesandbox.io/p/sandbox/hopeful-snyder-z0sjg1?file=%2Fsrc%2Ffeatures%2Fping%2Fcomponents%2FPing.tsx&selection=[{"endColumn"%3A1%2C"endLineNumber"%3A48%2C"startColumn"%3A1%2C"startLineNumber"%3A7}]

簡単ですが、以上です。