Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

TypeScript開発にRailway Orientedを持ち込み、より型安全なエラーハンドリングへ

Digitization部 Bill One Entry*1グループの秋山です。

はじめに

エラーハンドリングは重要な処理にも関わらず、静的型付け言語の恩恵を十分に活かしきれていない領域です。特にTypeScriptを用いた開発では、その発展途上な言語仕様も手伝い、型安全なエラーハンドリングはそもそも実現が難しいという実情がありました。

しかし昨今の周辺ライブラリの発展により、型安全なエラーハンドリングを実際の開発現場で実現できるようになってきました。

この記事ではScott Wlaschinの2018年の書籍『Domain Modeling Made Functional(以後、「DMMF本」)』で提唱されているRailway Oriented Programmingと呼ばれるパラダイムを借り、TypeScriptを用いた開発でどのように型安全なエラーハンドリングを担保できるかを検討していきます。

Domain Modeling Made Functionalというスゴ本

DMMF本は Tackle Software Complexity with Domain-Driven Design and F# という副題が示す通り、F#を用いてDDDをチュートリアル形式で学ぶという論旨です。比較的マイナーなF#を用いる所で抵抗のある方もいると思いますが、著者が前書きで述べている通り、書籍内のアイデアはF#以外の静的型付け言語を用いた開発でも広く適用可能です。

DMMF本には多くのアイデアが散りばめられていますが、この記事ではそのうち Railway Oriented Programming というエラーハンドリングのテクニックにフォーカスしています。

ここでハードルになるのが、F#に実装されているがTypeScriptには実装されていない言語仕様で、具体的には次の2つです。

  • パターンマッチング
  • パイプライン

いずれも関数型プログラミングにおいて重要な働きをします。F#とTypeScriptは共にマルチパラダイムな言語と見做されてますが、こと関数型言語として見た場合、まだTypeScriptはF#ほどには成熟していません。

しかしTypeScriptであっても、パターンマッチングパイプライン はユーザーランドで実装可能あり、これらの機能を提供する優れたライブラリが存在します。この記事ではそれらのライブラリのメンテナに感謝しつつ使用しながら論を進めます。

なお、Railway Oriented ProgrammingはDMMF本の著者が自身のブログでも紹介しています*2

fsharpforfunandprofit.com

補講:Make Illegal States Unrepresentable

エラーハンドリングのスコープからは外れますが、DMMF本の中で提唱される重要な警句として Make Illegal States Unrepresentable があります。

要約すると 有り得る状態だけを型定義するべき という主張で、具体的には下記サンプルコードに示すような状態を指します。

/** ⛔️ アンチパターン */
type User = {
  name: string;
  emailVerified: boolean;
  email: string | null;
};

/** ✅ 望ましい型定義 */
type User = {
  name: string;
} & (
  /** 認証済みの場合確実にメールアドレスが存在する */
    {
      emailVerified: true;
      email: string;
    }
  /** 認証済みでない場合メールアドレスは null */
  | {
      emailVerified: false
      email: null;
    }
)

アンチパターンとして示した前者の型定義の方が簡潔ですが、後者のように型が取りうる状態を狭めることで、より型安全な開発の強制が可能です。もちろんエラーハンドリングでも威力を発揮します。

静的型付け言語による開発には、型を可能な限り厳密に定義する態度をデフォルトとし、型の緩和はイレギュラーとして扱う、というセオリーがあります。これは主にフロントエンドにおけるテスト戦略として広く受け入れられつつあるTesting Trophyの考えとも親和しています。

📝 Testing Trophyは下記の記事で概説しています。

buildersbox.corp-sansan.com

バックエンドの処理を抽象化する

一つのAPIエンドポイントの仕事を抽象化すると、リクエストという入力に対してレスポンスという出力を吐き出す単一の関数と見なせます。さらにその関数は、より小さな関数(以後、「サブ関数」)を連結したパイプラインとして構築できます。DBの読み書きやネットワーク通信は、関数の副作用として扱います。

ここでは例として、ユーザー名の変更を受け付けるPUTリクエストのAPIハンドラを考えます。このAPIハンドラは、次に示すようなサブ関数の連結として解釈できます。

  1. リクエストからパラメータを抜き出す
  2. パラメータをバリデートする
  3. DBレコードを更新する
  4. ユーザーにメールを送信する
  5. レスポンスを生成する

バックエンドにおける実装業務とは、主にこのようなパイプラインの構築作業と言い換えられます。

手続き型プログラミングの典型例

上記のAPIハンドラを手続き型プログラミングで実装する場合、典型的には下記のようなコードになります。

/**
 * リクエストを元にユーザー名を変更する
 */
async function handleUpdateUser(
  request: express.Request,
  response: express.Response
): Promise<void> {
  try {
    // 1. リクエストからパラメータを抜き出す
    const params = {
      userId: request.session.userId,
      name: request.params.newName,
    };

    // 2. パラメータをバリデートする
    const isValid = validateName(params.name);
     if (!isValid) throw new ApiError(400, `Your name is invalid.`);
    
    // 3. DBレコードを更新する
    const result = await updateUserInDB(params.userId, params.name);
    if (!result.success) throw new ApiError(500, `Can't update user's name.`);

    // 4. ユーザーにメールを送信する
    await sendVerificationEmail(params.email);
    
    // 5. レスポンスを生成する(正常系)
    response.status(200).json({ userId: params.userId });
  } catch(error) {
    // 5-b. レスポンスを生成する(異常系)
    response
      .status(error.status || 500)
      .json({ message: error.message || "Internal error." });
  }
}

一見よくある実装例ですが、このコードにおける問題点は何でしょうか?ここではペインの大きな3つの課題を挙げます。

課題1:制約のないエラーハンドリング

例外スロー、Result型、単純なboolean返却など、多様な形で表現されたエラーが混在しています。必然的にhandleUpdateUser関数ではそれらのエラーに対して個別の対応が必要になっています。

メンタルモデルは定義の難しい概念ですが、ここではエンジニアが設計や実装を行う上で頭の中に描く抽象イメージを指すことにします。例えば前章で述べた、APIエンドポイントの仕事はサブ関数のパイプライン、という抽象イメージがまさにメンタルモデルに当たります。

エラーハンドリングの形が多様になると、このメンタルモデルの複雑性が高まり、肝心の設計や実装に割ける脳内メモリが圧迫されてしまいます。

課題2:低い可読性

可読性が高いと言われるコードの体現方法として、「自己文書化されたコード」というアイデアがあります。この言葉も明確な定義がありませんが、ここではコードがあたかも自然言語で書かれているような状態を指します。

handleUpdateUser関数の処理は、サブ関数を逐次的に呼び出しているだけですが、エラー表現が多様なせいで自己文書化されたコードとは程遠い実装です。

プログラミング言語の発展の歴史を振り返ると、可読性の向上が一つの大きな進化の方向性と言えます。始めに機械語からアセンブリ言語への転向があり、FORTRAN(1957年)の誕生を皮切りに発展した高水準言語は、数学や英文に近い構文を取り入れて可読性の向上を図ってきました。

パイプラインを始めとする言語仕様や、関数型プログラミングといったパラダイムも、この大きな方向性から必然的に生まれた発明という解釈も可能でしょう。

課題3:エラーハンドリングの低い網羅性

サンプルコードは、各サブ関数のエラーを全て例外に変換した後、 エラーハンドリングはcatch節 に集約させるという整理になっています。この書き方の問題は、例外への変換過程でエラーの種類という重要な型が抜け落ちてしまっている点です。つまりcatch節のerror定数がunknown型である点です。

もちろんcatch節のブロックの中で、error定数のインスタンスタイプによって分岐処理を書くことは可能です(例えばメール送信エラーの場合は対象のメールアドレスをロギングする、など)。ですが、サブ関数が出力し得る、全てのエラーの種類を、catch節が網羅的に考慮できているかどうかは、サンプルコードでは担保できていません。

Railway Oriented Programming

Railway Oriented Programmingは、DMMF本で提唱されているエラーハンドリングの手法で、関数をレールと見なすメタファーが特徴です。ここでは順を追って要点を概説します。

下図は入力と出力が1:1の関数を、単線のレールとして表現しています。

1:1の関数

もし関数がエラーを返す場合、下図のようにレールは二股になります。

1:2の関数

関数型プログラミングでは、文の代わりに式を使い実装を行います。文と式の明確な定義は専門書や他の解説に譲り、ここでは実用的な相違点とサンプルコード *3だけを記載します。

  • 式:値を生成するもの
  • 文:値を生成しないもの
let result; // 文: 変数定義

result = 5 * 3; // 文: 変数代入; `5 * 3` は 式

if (result > 10) { // 文: if 文
  console.log("Result is greater than 10."); // 文: console.log 関数呼び出し
} else {
  console.log("Result is 10 or less."); // 文: console.log 関数呼び出し
}

const double = (x: number): number => x * 2; // 文: 関数定義; `x * 2` は 式

console.log(double(4)); // 文: console.log 関数呼び出し; `double(4)` は式

下図のように、Railway Oriented Programmingでは複数のサブ関数に対し、文を使わず式で連結させようとします。

サブ関数の連結

ところが、各サブ関数の入力は「一つ前の正常系の出力」のみを期待しているため、このままでは連結できません。そこで下図のように、各サブ関数の入力も二股にすることで連結させます。

2:2の関数にして連結させる

この結果、正常系は緑のレールに沿って実行が進み、いずれかのサブ関数でエラーが生じた場合は赤いレールへ進むというシンプルな整理ができるようになりました。

TypeScriptを使いサブ関数を連結させパイプラインを構築し、さらに型的に安全なエラーハンドリングを実現するには、下記の4つのステップが必要です。

  • ステップ1:サブ関数の出力はResult型で表現する
  • ステップ2:サブ関数にResult型を入力できるようにする
  • ステップ3:サブ関数を連結する
  • ステップ4:網羅的にエラーハンドリングする

順番に見ていきましょう。

TypeScriptで型安全にエラーハンドリングする

ステップ1:サブ関数の出力はResult型で表現する

下記のようなResult型を用意し、サブ関数の返り値はこのResult型に統一します。

type Failure = { errorCode: number };
type Result<Ok, Ng extends Failure> = {
    success: true;
    data: Ok;
} | {
    success: false;
    error: Ng;
}

Failure型はサブ関数の異常時の返り値に対応します。ここでは簡単のためerrorCodeというnumber型を内包しているだけですが、実際の開発では標準Errorクラスのサブクラスなどを設定します。

Result型は正常時または異常時の返り値のいずれかを内包します。このResult型を返す形でサブ関数を実装すると、例えば下記のような定義になります。

/**
 * 2. パラメータをバリデートするサブ関数
 */
function validateName(
  args: { id: number, name: string }
): Result<{ id: number, name: string }, { errorCode: 100 }> {
  return args.name !== ""
    ? { success: true, data: args }
    : { success: false, error: { errorCode: 100 } }
}

入力されるパラメータに問題がなければそのまま入力をResult型にして返し、問題があれば異常系のResult型を返しています。

ステップ2:サブ関数にResult型を入力できるようにする

各サブ関数が前のサブ関数の正常な出力をその入力として想定しています。このため、サブ関数を連結することができません。

そこでサブ関数をResult型を受け取れる形に変換する、薄いラッパー関数を作ります。ここではそのラッパー関数をbypass関数と名付けます。

/**
 * サブ関数をResult型を入力できる関数に変換する
 *
 * 変換後のサブ関数は、入力( = 一つ前のサブ関数の出力)によって次のように分岐処理する
 * (A) 入力が正常系Resultの場合:オリジナルのサブ関数を呼び出し、その返り値を返す
 * (B) 入力が異常系Resultの場合:入力をそのまま返す
 */
function bypass<
  PreviousOk,
  PreviousNg extends Failure,
  NextOk,
  NextNg extends Failure
>(
  func: (i: PreviousOk) => Result<NextOk, NextNg>, // 引数はオリジナルのサブ関数
): (input: Result<PreviousOk, PreviousNg>) => Result<NextOk, PreviousNg | NextNg> {
  return (input) => input.success ? func(input.data) : input;
}

bypass関数により変換されたサブ関数は、下記のようにシグネチャが変更されます。

/**
 * BEFORE:引数は正常系のデータ型のみ
 */
type Before = Parameters<typeof validateName>;
/** { id: number; name: string; } */

/**
 * AFTER:引数はResult型
 */
type After = Parameters<typeof bypass(validateName)>;
/** Result<{ id: number; name: string; }, Failure> */

ステップ3:サブ関数を連結する

本来であればTypeScriptのPipeline Operator構文を使いたいのですが、Pipeline Operatorは2024年3月現在でstage-2段階のフェーズにあり仕様が定まっていません。

github.com

Ramda.js は関数型プログラミングに必要な関数群を提供してくれるライブラリです。そのうちの一つにpipe関数があり、これを使うことでPipeline Operatorに準じた実装が可能になります。

www.npmjs.com

import { pipe } from "ramda";

async function handleUpdateUser(request: express.Request): Promise<void> {
  pipe(
    extractParams(request),        // 1. リクエストからパラメータを抜き出す
    bypass(validateEmail),         // 2. パラメータをバリデートする
    bypass(updateUserInDB),        // 3. DBレコードを更新する
    bypass(sendVerificationEmail), // 4. ユーザーにメールを送信する
    bypass(sendResponse),          // 5. 正常系のレスポンスを生成する
    (result) => {
      /** ここでエラーハンドリングする */
    }
  }
}

最初のサブ関数にはbypassを適用していないことに注意してください。最初のサブ関数の引数はResult型である必要がありません。

なお、もしPipeline Operatorが使えるようになった場合は、下記のような形で実装できるはずです(あくまでイメージです)。

// NOTE: Pipeline Operator (Stage: 2) を利用する場合
async function handleUpdateUser(request: express.Request): Promise<void> {
  const result = extractParams(request)   // 1. リクエストからパラメータを抜き出す
    |> bypass(validateEmail)(%)           // 2. パラメータをバリデートする
    |> bypass(updateUserInDB(%)           // 3. 既存のDBレコードを更新する
    |> bypass(sendVerificationEmail)(%)   // 4. 確認メールを送信する
    |> bypass(sendResponse)(%)            // 5. 正常系のレスポンスを生成する
    |> (result) => {
      /** ここでエラーハンドリングする */
    }
}

ステップ4:網羅的にエラーハンドリングする

パイプラインにおける最後のサブ関数には、パイプラインの中で生じ得る、全てのエラーをハンドリングする責任があります。そのために必要なピースは下記の2つです。

生じ得る全てのエラーの型を抽出できる

これは既に完了しています。最後のサブ関数の引数であるResult型は、それまでのサブ関数が返し得る異常系の型を全て知っています。

網羅的な分岐処理が書ける

ユーザーランドでパターンマッチングを実現できるライブラリ ts-pattern を用います。次のように網羅的な分岐処理を静的に保証できます。

import { match } from "ts-pattern";

type Role = "admin" | "member" | "guest";
const role: Role = ...;

match(role)
    .with("admin", () => {...})
    .with("member", () => {...})
  .exhaustive(); /** ⛔️ "guest" に対する分岐がないため型エラーとなる */

📝 ts-patternについては下記の記事でも紹介しています。

zenn.dev

以上で準備は整いました。次のようにエラーハンドリングできます。

import { pipe } from "ramda";
import { match } from "ts-pattern"

async function handleUpdateUser(request: express.Request): Promise<void> {
  const result = pipe(
    extractParams(request),        // 1. リクエストからパラメータを抽出する
    bypass(validateEmail),         // 2. パラメータをバリデートする
    bypass(updateUserInDB),        // 3. 既存のDBレコードを更新する
    bypass(sendVerificationEmail), // 4. 確認メールを送信する
    bypass(sendResponse),          // 5. 正常系のレスポンスを生成する
    /** 網羅的なエラーハンドリングを型で保証する */
    (result) => match(output)
      /** 1. extractParams で発生し得るエラー */
      .with({ error: { errorCode: 100 } }, () => { ... })
      /** 2. validateEmail で発生し得るエラー */
      .with({ error: { errorCode: 200 } }, () => { ... })
      /** 3. updateUserInDB で発生し得るエラー */
      .with({ error: { errorCode: 300 } }, () => { ... })
      /** 4. sendVerificationEmail で発生し得るエラー */
      .with({ error: { errorCode: 400 } }, () => { ... })
      /** 5. sendResponse で発生し得るエラー */
      .with({ error: { errorCode: 500 } }, () => { ... })
      /** 正常系なら何もしない */
      .with({ success: true }, () => {})
      .exhaustive() /** 💡 全てのエラーに対応できていない場合は型エラーになる */
  )
}

おわりに

この記事では関数型プログラミングにおける主要な言語仕様であるパイプラインとパターンマッチングを、ライブラリの力を借りながらTypeScript開発に持ち込み、網羅的なエラーハンドリングを実践する手法を検討しました。

これにより従来の手続き型的なプログラミングを脱することで、次のようなメリットが期待できます。

  • エラーの表現方法を統一しメンタルモデルをシンプルに保てる
  • エラーハンドリングの網羅性を型的に保証できる
  • 一連の処理をサブ関数の連結として表現することで可読性が向上する

※この記事の作成に当たり、Digitization部の小田 崇之, 薩田 和弘, 湯村 直樹 に助言をもらいました。

付録

TypeScriptの全文サンプル

下記のバージョンで動作確認しています。

  • typescript:5.3.3
  • ramda:0.29.1
  • ts-pattern:5.0.8
import { pipe } from 'ramda';
import { match } from 'ts-pattern';

type Result<Ok, Ng> = {
  success: true;
  data: Ok;
} | {
  success: false;
  error: Ng;
}
type Failure = { errorCode: number };

type Func1Output = "func1's success output";
type Func1Error = { errorCode: 100 };
function func1(p: any): Result<Func1Output, Func1Error> {
  return {
      success: true,
      data: "func1's success output"
  };
}

type Func2Output = "func2's success output";
type Func2Error = { errorCode: 200 };
function func2(p: Func1Output): Result<Func2Output, Func2Error> {
  return {
      success: true,
      data: "func2's success output"
  };
}

type Func3Output = "func3's success output";
type Func3Error = { errorCode: 300 };
function func3(p: Func2Output): Result<Func3Output, Func3Error> {
  return {
      success: true,
      data: "func3's success output"
  };
}

function bypass<
  PreviousOk,
  PreviousNg extends Failure,
  NextOk,
  NextNg extends Failure
>(
  func: (i: PreviousOk) => Result<NextOk, NextNg>,
): (input: Result<PreviousOk, PreviousNg>) => Result<NextOk, PreviousNg | NextNg> {
  return (input) => input.success ? func(input.data) : input;
}

function main() {
  pipe(
    func1,
    bypass(func2),
    bypass(func3),
    (result) =>
      match(result)
        .with({ success: true }, () => {})
        .with({ error: { errorCode: 100 } }, () => {})
        .with({ error: { errorCode: 200 } }, () => {})
        .with({ error: { errorCode: 300 } }, () => {})
        .exhaustive()
  )(123);
}



20240312182329

*1:クラウド請求書受領サービス「Bill One」が提供するデータ化機能。

*2:この記事の中で用いた画像はScott Wlaschinの登壇スライドから切り出していますが、氏が画像使用をブログ内で許諾されています。

*3:このサンプルコードでは void を返す関数呼び出しも文に分類しています。void を返す関数は、副作用を起こすだけの関数であるため、値を生成しているとは言えないという理由です。

© Sansan, Inc.