1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【TypeScript】try...catchに頼らない型安全なエラーハンドリング

Posted at

はじめに

TypeScriptでエラーを処理するとき、あなたは普段どのような方法を使っていますか?通常は次のようにtry...catchブロックを使いますね:

function riskyOperation(): string {
  try {
    // 危険な処理
    if (Math.random() < 0.5) {
      throw new Error("失敗しました");
    }
    return "成功しました";
  } catch (error) {
    // エラー処理
    console.error(error);
    return "エラーが発生しました";
    // または throw error; で再スロー
  }
}

この方法には、いくつかの不満がありました:

  1. 型の安全性がない: エラーの型がanyまたはunknownになり、どのような種類のエラーが発生するかコンパイル時に把握できない
  2. 暗黙的な制御フロー: 関数のシグネチャからは、その関数が例外をスローする可能性があるかどうかわからない
  3. 捕捉すべき場所が不明確: どこでエラーをキャッチすべきか、コードから読み取りにくい

本記事では、これらの問題を解決する2つのアプローチ「Effect」と「ts-result」を紹介します。これらのライブラリを使うことで、例外に頼らず、型安全で明示的なエラー処理を実現できます。

従来のエラー処理の問題点

従来のエラー処理手法には、主に次の2つの問題があります:

1. try...catchの問題点

// 例外を使ったエラー処理
function divide(a: number, b: number): number {
  if (b === 0) {
    throw new Error("ゼロ除算エラー");
  }
  return a / b;
}

try {
  const result = divide(10, 0);
  console.log(result);
} catch (error) {
  console.error("エラーが発生:", error);
}
  • 型の情報がない: TypeScriptの型システムは例外をモデル化できない
  • 追跡が困難: どの関数が例外をスローするか静的に把握できない
  • 明示性の欠如: 関数のシグネチャから例外発生の可能性が読み取れない

2. null/undefinedを返す方法の問題点

// null/undefinedを返すエラー処理
function divide(a: number, b: number): number | null {
  if (b === 0) {
    return null;
  }
  return a / b;
}

const result = divide(10, 0);
if (result === null) {
  console.error("エラーが発生: ゼロ除算");
} else {
  console.log(result);
}
  • エラー情報が失われる: なぜエラーが発生したのか詳細情報がない
  • 意図的なnullと区別できない: 正当なnull値とエラーを表すnullを区別できない
  • 型安全性が不完全: TypeScriptのコンパイラが確実にnullチェックを強制しない場合がある

型安全なエラー処理へのアプローチ: Result型パターン

Rust、Haskellなどの関数型言語では、エラー処理のために「Result型」または「Either型」と呼ばれるパターンが使われています。このパターンでは、関数の戻り値として「成功」または「失敗」のいずれかを表す型を使用します。

TypeScriptでこのパターンを実現するために、今回は2つのライブラリを紹介します:

  1. ts-result: RustのResult型を直接移植したシンプルなライブラリ
  2. Effect: より包括的な関数型プログラミングのエコシステム

ts-result: シンプルな型安全エラーハンドリング

ts-resultは、RustのResult<T, E>型をTypeScriptに移植したライブラリです。2つの結果(成功または失敗)を明示的に表現できます。

インストール

npm install @trylonai/ts-result
# または
yarn add @trylonai/ts-result

基本的な使い方

import { Ok, Err, Result } from '@trylonai/ts-result';

// Result型を返す関数
function divide(a: number, b: number): Result<number, string> {
  if (b === 0) {
    return Err("ゼロ除算エラー");
  }
  return Ok(a / b);
}

// 使用例
const result = divide(10, 0);

// 型ガードによる分岐
if (result.isOk()) {
  // TypeScriptは自動的にresult.valueの型を認識
  console.log("結果:", result.value);
} else {
  // TypeScriptは自動的にresult.errorの型を認識
  console.error("エラー:", result.error);
}

// パターンマッチング風の分岐
result.match({
  Ok: value => console.log("結果:", value),
  Err: error => console.error("エラー:", error)
});

// デフォルト値の提供
const safeResult = result.unwrapOr(0); // エラーの場合は0を返す

try...catchをResultに変換する

既存のコードをts-resultに移行するとき、try...catchブロックをResult型に変換するヘルパー関数の書き方ができます:

import { Ok, Err, Result } from '@trylonai/ts-result';

// try...catchをResultに変換する関数
function tryCatch<T, E = Error>(fn: () => T): Result<T, E> {
  try {
    return Ok(fn());
  } catch (error) {
    return Err(error as E);
  }
}

// JSONパースの例
function parseJSON(json: string): Result<unknown, Error> {
  return tryCatch(() => JSON.parse(json));
}

// 使用例
const validResult = parseJSON('{"name": "John"}');
const invalidResult = parseJSON('{name: John}');

validResult.match({
  Ok: data => console.log("パース成功:", data),
  Err: error => console.error("パース失敗:", error.message)
});

連鎖的な処理とエラーハンドリング

複数の操作を連鎖させる場合、andThenメソッドを使って前の操作が成功した場合にのみ次の操作を実行できます:

import { Ok, Err, Result } from '@trylonai/ts-result';

// ユーザーデータを取得する関数
function fetchUserData(id: string): Result<{ name: string }, string> {
  if (id === "1") {
    return Ok({ name: "John" });
  }
  return Err("ユーザーが見つかりません");
}

// ユーザーの注文履歴を取得する関数
function fetchUserOrders(user: { name: string }): Result<string[], string> {
  if (user.name === "John") {
    return Ok(["注文1", "注文2"]);
  }
  return Err("注文履歴が見つかりません");
}

// 連鎖的な処理
const orders = fetchUserData("1")
  .andThen(user => fetchUserOrders(user))
  .unwrapOr([]); // エラーの場合は空配列

console.log(orders); // ["注文1", "注文2"]

// 別のユーザーID(存在しないユーザー)
const noOrders = fetchUserData("999")
  .andThen(user => fetchUserOrders(user))
  .unwrapOr([]);

console.log(noOrders); // []

Effect: 包括的なエラーハンドリングとその先へ

Effectはより広範な機能を持つライブラリで、エラーハンドリングだけでなく、非同期処理、依存性注入、リソース管理などをサポートしています。

インストール

npm install effect
# または
yarn add effect

基本的な使い方

import { Effect } from 'effect';

// エラー型を定義
class DivisionByZeroError extends Error {
  readonly _tag = 'DivisionByZeroError';
  constructor() {
    super('ゼロ除算エラー');
  }
}

// Effect型を返す関数
function divide(a: number, b: number) {
  if (b === 0) {
    return Effect.fail(new DivisionByZeroError());
  }
  return Effect.succeed(a / b);
}

// 実行
const program = divide(10, 2)
  .pipe(
    Effect.map(result => `結果: ${result}`),
    Effect.catchAll(error => 
      Effect.succeed(`エラーが発生: ${error.message}`)
    )
  );

// Effect値の実行(これにより初めて計算が行われる)
Effect.runPromise(program).then(console.log); // "結果: 5"

// ゼロ除算の場合
const errorProgram = divide(10, 0)
  .pipe(
    Effect.map(result => `結果: ${result}`),
    Effect.catchAll(error => 
      Effect.succeed(`エラーが発生: ${error.message}`)
    )
  );

Effect.runPromise(errorProgram).then(console.log); // "エラーが発生: ゼロ除算エラー"

Effectの重要な特徴は、操作が即座に実行されないことです。代わりに、実行されるべき操作の説明(プログラム)を作成し、runPromiseなどのランナー関数でそれを評価します。

try...catchをEffectに変換する

既存のコードをEffectに移行するために下記のような関数が書けます:

import { Effect } from 'effect';

// try...catchをEffectに変換する関数
function tryCatchEffect<A, E = Error>(f: () => A): Effect.Effect<A, E> {
  return Effect.try({
    try: f,
    catch: error => error as E
  });
}

// JSONパースの例
function parseJSON(json: string) {
  return tryCatchEffect(() => JSON.parse(json));
}

// 使用例
const program = parseJSON('{"name": "John"}')
  .pipe(
    Effect.map(data => `パース成功: ${JSON.stringify(data)}`),
    Effect.catchAll(error => 
      Effect.succeed(`パース失敗: ${error.message}`)
    )
  );

Effect.runPromise(program).then(console.log);

連鎖的な処理とエラーハンドリング

import { Effect, pipe } from 'effect';

// ユーザーデータを取得する関数
function fetchUserData(id: string) {
  if (id === "1") {
    return Effect.succeed({ name: "John" });
  }
  return Effect.fail(new Error("ユーザーが見つかりません"));
}

// ユーザーの注文履歴を取得する関数
function fetchUserOrders(user: { name: string }) {
  if (user.name === "John") {
    return Effect.succeed(["注文1", "注文2"]);
  }
  return Effect.fail(new Error("注文履歴が見つかりません"));
}

// 連鎖的な処理
const program = pipe(
  fetchUserData("1"),
  Effect.flatMap(fetchUserOrders),
  Effect.catchAll(error => Effect.succeed([]))
);

Effect.runPromise(program).then(console.log); // ["注文1", "注文2"]

ts-result と Effect の比較

共通点

  • どちらも型安全なエラー処理を提供
  • どちらも関数型プログラミングのアプローチを採用
  • どちらもエラーを値として扱う
  • どちらも連鎖的な処理をサポート

違い

特徴 ts-result Effect
複雑さ シンプル 高度
学習曲線 比較的緩やか 比較的急(公式ドキュメントより)
実行モデル 即時評価 遅延評価
機能範囲 エラーハンドリングに特化 包括的な関数型プログラミング機能セット
依存性注入 含まれていない 組み込みサポートあり
非同期処理 基本的なサポート 高度なサポート

いつts-resultを選ぶべきか

  • シンプルな型安全エラー処理だけが必要な場合
  • 既存のコードベースへの最小限の変更で導入したい場合
  • バンドルサイズを小さく保ちたい場合
  • 関数型プログラミングの概念を学び始めたばかりの場合
  • Rustのエラーハンドリングパターンに馴染みがある場合

いつEffectを選ぶべきか

  • より包括的な関数型プログラミングのアプローチを求める場合
  • 非同期処理、依存性注入、リソース管理も統一的に扱いたい場合
  • 複雑なエラー回復戦略が必要な場合
  • アプリケーション全体での一貫したアプローチを求める場合
  • 大規模なプロジェクトでの長期的な拡張性を重視する場合

まとめ

TypeScriptでエラー処理を行う際に、try...catchに頼るだけでなく、型安全で明示的なアプローチを採用することには多くのメリットがあります:

  1. 型の安全性: コンパイル時にエラーハンドリングの漏れを検出できます
  2. 明示的なエラー処理: 関数のシグネチャからエラーの可能性を把握できます
  3. 合成可能性: 関数を安全に組み合わせて複雑な操作を構築できます

本記事で紹介した2つのライブラリは、それぞれ異なるアプローチでこれらの目標を達成します:

  • ts-result: シンプルで導入しやすく、Rustのパターンに近いアプローチ
  • Effect: より包括的な機能を持ち、大規模プロジェクトに適した機能を提供

どちらも便利そうですね。これまで自分でResult型をつくるなどしていました。これからはts-resultやEffectを使ってみようと思います。

参考リンク

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?