🎄

実例 Result<T, E> / TypeScript一人カレンダー

2024/12/21に公開

こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2024の16日目です。昨日は『App Router 時代のエラーハンドリング』を紹介しました。

エラーの系統を考察する

昨日の記事で、TypeScriptプログラミングにおけるエラーハンドリングが、React Server ComponentsNext.js App Routerといった、TypeScriptプログラミングを超えた要因に左右されると紹介しました。そして同様のことは、プラットフォームでも起こることを紹介します。

筆者が技術顧問を務める株式会社トレタでは、飲食店向けのモバイルメニューサービスを提供しており、複数社の大手POSシステム(レジ、販売管理システムのこと)との連携が可能なつくりになっています。

このような環境では、エラー発生の系統が多岐にわたります。一度これらを整理します。

  • サービス開発側(筆者ら)の実装誤りによって発生するエラー
  • 外部ベンダー側のシステム障害などによって発生するエラー
  • エンドユーザー(飲食店利用客)の操作ミスや無効なフォーム入力によって発生するエラー
  • 飲食店スタッフが誤ったメニューデータを登録したり、商品登録時に漏れがあったことによって発生するエラー

この中で、4つ目のエラー原因は業種特有のものでしょう。このように、エラーは単にバグや障害だけでなく、外部要因や業務上の運用ミスなど、様々な系統で発生します。エラーといえば開発側のバグ、システム障害、エンドユーザーの操作誤りだけで起こると思い込んでいると、このような「エンドユーザーの操作にも開発側の実装にも問題がなく、システムも正常に動作しており、なのにエラーとなる」という状況は、慣れていないと驚きに見えるかもしれません。こういった登場人物の多いサービスを設計・開発していると、To B向けアプリケーション、To C向けアプリケーション両方のエラーハンドリング設計経験が求められます。

トレタ社では、飲食店スタッフの方々がすぐに対応できるように、エラーコードを画面上に表示する仕組みを整えています。これは家電でいうと、まるで洗濯機のエラーコードの数字を見て取扱説明書のエラー一覧から意味を確認するような感覚です。

プラットフォームがエラーメッセージを加工する盲点

こうしたサービス利用者向けに提示するエラーコードの送受信と表示の仕組みを実装し、開発中は問題なく動作していました。しかし本番環境で検証を進めると、なぜかエラーコードが画面に表示されずInternal Server Errorページに遷移してしまうケースが発生しました。

あまり聞き馴染みのない不具合報告を受け、このときは原因特定に苦戦しましたが、原因を追究するとエラーメッセージ自体が見知らぬ内容に書き換わっていたことを発見しました。そのため、我々が用意したエラーコード・パーサーが正しく動作できず、結果的にInternal Server Error扱いになっていました。

その時に出力されたエラーメッセージは以下のものです。

An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.

どうやら、エラーメッセージに万が一クレデンシャル情報がそのまま含まれてしまい、その情報漏洩が起こるというリスクを防ぐため、デプロイ先のプラットフォームであるVercelが意図的にエラーメッセージをマスクしていたのです。その結果、想定したエラーコードを含むメッセージをパースできなくなり、予期せぬ結果となっていました。

昨日ReactNext.jsによるエラーハンドリング特性を踏まえるべきと述べましたが、それに加えてVercelの動作もまた、考慮の対象となることが判明したのです。これは今まで一度も見たことのない挙動であり盲点でしたが、理由を考えてみれば無理もないことだと納得できます。

throwからResult<T, E>への転換

当初の設計では、エラーが発生したらthrowし、それをキャッチしてエラーコードページへリダイレクトし、エラーコードを表示するという想定でした。しかし、プラットフォームがメッセージを加工してしまう以上、throwしてしまうと、どうやってもメッセージが書き換えられてしまいました。

そこで発想を転換し、処理が正常に終了したときのレスポンスと、処理が失敗した際のエラーコードを「両方正常系」として返却することにしました。ページ名もエラーコードページと呼ばれてはいますが、それが必ずthrowによって到達しなければならないわけではありません。Next.jsでのredirect()などを使って、正常なレスポンスとしてエラーコード表示ページへ遷移すること自体はまったく問題ないわけです。

このフローをまず仮で実装し、本番環境でも問題なくエラーコードが画面上に表示されることを確認しました。しかしこうなると、これまでのthrow前提の実装を改修し、正常系T型とエラーコードE型を混ぜ込んだユニオン型をどの処理も逐一返すには、改修工数が足りるか不安となります。

Result<T, E>型

工数に余裕がない状況だったため、大規模工事にならない、かつ信頼できる仕組みを考える上で、他社の前例を調査しました。そこで筆者はResult<T, E>型の概念を思い出し、この採用を決断しました。

Result<T, E>型は、関数型プログラミング界隈でよく知られたEitherモナドから着想を得た仕組みです。HaskellRustで一般的な「成功か失敗か」を値として表現する型で、T型が成功値の型、E型がエラー型を表します。

この仕組みを、完全ではなく部分的に拝借しましょう。今回はエラーコードをE型として返し、Tには本来の成功時の値を詰めることができます。throwしないので、プラットフォームによるメッセージ書き換えを確実に回避できます。

export type Result<T, E> =
  | Readonly<{ value: T; ok: true }>
  | Readonly<{ value: E; ok: false }>;

export function Ok<T>(v: T): Extract<Result<T, unknown>, { ok: true }> {
  return { value: v, ok: true };
}

export function Err<E>(v: E): Extract<Result<unknown, E>, { ok: false }> {
  return { value: v, ok: false };
}

成功時はOk()関数を、エラー時はErr()関数を使って返却値を作成します。

次の関数は、筆者が実際に業務で使用している実装にとても近いダミー関数です。多くの関数名は「雰囲気」で読んでもらうことを想定しており、Err()Ok()、そしてそれ以外のthrowがどのように組み合わさっているかに注目してください。

type ErrorCode = Readonly<{
  code: FilledString;
  message: string;
}>;

type AdaptedResponse = Readonly<{
  // なんらかの成功時の情報
}>;

async function createData(
  token: Token,
  name: FilledString,
): Promise<Result<AdaptedResponse, ErrorCode>> {
  const params = {
    url: "https://api.example/create-data",
    headers: { "Content-Type": "application/json" },
    body: { token, name },
  } satisfies Parameters<typeof fetchOtherSystem>[0];

  const res = await fetchOtherSystem(params);
  const json = await res.json();

  if (!res.ok) {
    const code = convertErrorCode(json);
    return Err(code); // エラーコードの場合、Errを返す
  }

  let parsed: SomeResponse;
  try {
    // Valibot を活用
    parsed = parse<InferOutput<typeof someResponse$>>(someResponse$, json);
  } catch (e) {
    // ここは Err() に拘らず、従来通り throw
    // このハンドリングは実際の業務コードの内容とは異なります
    if (e instanceof ValiError) {
      throw new PreconditionError("invalid response");
    }
    throw e;
  }

  return Ok(adaptFromResponse(parsed)); // 成功の場合、Okを返す
}

これでok: truefalseを見て成功か失敗か分岐でき、失敗時にエラーコードをvalueから取得してエラーコードページへ誘導できます。

import type { Pattern } from "./pattern";
import type { Result } from "./result";

export function match<T extends Result<any, any>, R>(
  result: T,
  pattern: Pattern<T, R>,
): R {
  if (result.ok) {
    return pattern.Ok(result.value);
  }

  return pattern.Err(result.value);
}

このようにmatch()関数を用意すれば、Result型に対してOkErrのパターンを明示し、きれいに処理を分岐できます。match()は次のように使用します。

async function handlePostData(req: Request, res: Response): void {
  const token = await fetchToken(req);
  const name = extractName(req);

  const result = await createData(token, name);

  match(result, {
    Ok: (response) => {
      const body = {}; // 何らかの body 作成処理
      res.status(201).json(body);
      return;
    },
    Err: (code) => {
      // 実際の業務コードはもう少し複雑です
      const query = makeQuery(code);
      redirect(["/error-code", query].join("?"));
      return;
    },
  });
}

try-catchではない書き方をすることで、Vercelのエラーメッセージ置換のフローに乗せないだけでなく、処理が他とあえて異なっていると視覚的に伝わる目的もあります。

さらなる欲求

Result<T, E>型を使っていくうちに、Promise<string>からstringを抜き出すように、number[]からnumberを抜き出すように、Result<T, E>からTだけ、Eだけを取り出したくなることもあります。そのためにInferOk<T>InferErr<T>を実装しました。

import type { Result } from "./result";

// 抽象型として全ての型を受け入れるため any を許容
export type InferOk<T extends Result<any, any>> = Extract<
  T,
  { ok: true }
>["value"];

// 抽象型として全ての型を受け入れるため any を許容
export type InferErr<T extends Result<any, any>> = Extract<
  T,
  { ok: false }
>["value"];

これによって成功値や失敗値の型を抽出でき、Awaited<T>, UnArray<T>と同様の発想で型操作が可能です。

type Return = Result<string, Error>;
type OkType = InferOk<Return> // string
type ErrType = InferErr<Return> // Error

戦略的な割り切り

2年前はthrowによるエラーハンドリングを推奨していた筆者ですが、今回はResult<T, E>型へ一部ロジックの切り替えを許容しました。これは「今後全てのthrowを廃止し、Result<T, E>で統一する」という極端な結論ではなく「特定の状況でエラーがthrowによって表せないならば、それはエラーと呼ぼうが正常系処理なのでResultで伝える」という柔軟な戦略です。

fp-tsなどのライブラリを導入すればより本格的な関数型スタイルで書けますが、今回は「エラーコードを正常系として返す」という単純な目的に特化したため、あえてライブラリの依存を増やす選択を取らずに、自作の最小限のResult<T, E>型で、できることを減らすという選択をしました。モナドにおなじみの.map()なども用意していません。

これはチーム内の議論によるものですが「エラーと呼ばれる値も正常系で返したい」という欲求に今回は特化するのみにしようという判断であり、アプリケーション全体のアーキテクチャや、社内ルールレベルにまで話題を広げたくないという判断でした。また、今後関数型にとても強い開発者がチームに参入したときに「エセResultであることに気付くだろうし、異質に見えてしまうのでは」という点も懸念に上がりました。なぜ本格的なResultではなくDiscriminated union止まりなのかという議論もしましたが、やはりJavaScriptである以上throw前提のエラーハンドリングを実装しないわけにはいかない、パラダイムを置き換えるにはライブラリの導入ではなく言語仕様の根本的な進化が必要、と結論付けました。

部分的な採用ということもあって、Result型のガイドラインを設けました。そして、基本的にはthrowで問題ない箇所はそのまま維持し、どうしても特定の状況で「エラーをreturnする」という明示的な手法が必要なときに限定してResult型を用いるようにしたのです。

10万行以上の規模のWebアプリケーションを開発していると、全てのルールを杓子定規で定義することはもはや不可能であり、全体的な理想の哲学・ポリシーを掲げつつ、個別の詳細なルールを場面ごとに柔軟に組み合わせることが、妥協点として求められてきます。時には不本意な判断を迫られることもあります。こうした現実的なバランス感覚こそが、実務でTypeScriptと、Webプラットフォーム、フレームワーク、ライブラリを組み合わせて開発するうえで欠かせない、Webアプリケーション開発者としての態度といえるでしょう。

学びと総括

最終的な振り返りとして、今回の経験から、筆者は次のような学びや指針を得ました。

前回と今回の記事で、プラットフォームやフレームワークがエラーを加工し、従来のthrow主体のエラーハンドリングがうまくいかなくなるケースがあるとわかりました。プラットフォームやフレームワークが、エラーオブジェクトやメッセージを加工・置換してくるというケースがあることを身をもって知った以上、状況次第で特定のエラーハンドリング手法は期待を裏切られることになる、という前提で考える必要があります。

そうしたとき、執着せずにerror.message.includes('CustomError:')といった古典の手法に立ち返ったり、Result<T, E>型のような他言語・関数型パラダイムの仕組みを参考にすることで、スムーズに問題を回避できました。

そのために「TypeScriptプログラミングにおいては、エラーだから必ずthrowしなければならない」という先入観を持たないようにし、処理フローの本質的な形に目を向けて実装していきます。throwが使えなければ、Result<T, E>型を導入してエラー値も正常系として表現すればよいという発想に至ったのは、他言語のエラー文化や歴史、概念を日頃から学んでいたからこそでした。

納期が切迫した状況などで不測の事態に直面した際も、慌てず別の案を考えられる柔軟性を身につけておくことが実務上、重要だと考えています。そして、筆者としては2年前に不採用だと思っていたアイデアや概念が、今でなら有用と判断できる場面が来るというのも面白さであり、常に新たな知見や発想を蓄えておき、常に機会をうかがうべきであるという意義として実感しました。

明日は『実例 ConvenienceFixture, orDefault()』

本日は『実例 Result<T, E>』を紹介しました。明日は『実例 ConvenienceFixture, orDefault()』を紹介します。それではまた。

Discussion