T-CREATOR

TypeScript による型安全なエラーハンドリング:Result 型と Neverthrow の活用

TypeScript による型安全なエラーハンドリング:Result 型と Neverthrow の活用

TypeScript での開発において、エラーハンドリングは避けて通れない重要な要素です。従来の JavaScript や TypeScript ではtry-catch文を使ったエラー処理が一般的でしたが、型安全性の観点から多くの課題が指摘されています。

この記事では、関数型プログラミングの概念を取り入れた「Result 型」と、その TypeScript 実装である「Neverthrow」ライブラリを活用した、より安全で保守性の高いエラーハンドリング手法をご紹介します。基礎的な概念から実際のアプリケーションでの活用方法まで、段階的に学んでいきましょう。

背景

JavaScript のエラーハンドリングの歴史

JavaScript のエラーハンドリングは長い変遷を経て現在の形になりました。

初期の JavaScript では、エラーハンドリングの仕組みが十分ではありませんでした。エラーが発生すると処理が止まってしまい、適切な対応が困難でした。

javascript// 初期のJavaScript - エラーハンドリングが困難
function divide(a, b) {
  return a / b; // ゼロ除算のチェックなし
}

const result = divide(10, 0); // Infinity が返される

ES5 の導入により、try-catch文が正式に標準化され、例外処理の仕組みが確立されました。

javascript// ES5での try-catch 文の活用
function divideWithErrorHandling(a, b) {
  try {
    if (b === 0) {
      throw new Error('Division by zero is not allowed');
    }
    return a / b;
  } catch (error) {
    console.error('Error occurred:', error.message);
    return null;
  }
}

非同期処理の普及に伴い、Promise ベースのエラーハンドリングが重要になりました。

javascript// Promise ベースのエラーハンドリング
function fetchUserData(userId) {
  return fetch(`/api/users/${userId}`)
    .then((response) => {
      if (!response.ok) {
        throw new Error('Failed to fetch user data');
      }
      return response.json();
    })
    .catch((error) => {
      console.error('API Error:', error);
      return null;
    });
}

TypeScript が解決する型安全性の課題

TypeScript の登場により、コンパイル時の型チェックが可能になりましたが、従来のエラーハンドリングには依然として課題が残っています。

TypeScript では基本的な型安全性は提供されますが、エラー処理については十分ではありません。

typescript// TypeScript での基本的なエラーハンドリング
interface User {
  id: number;
  name: string;
  email: string;
}

function getUserById(id: number): User | null {
  try {
    // データベースから取得する処理(仮想)
    const userData = database.findUser(id);
    return userData;
  } catch (error) {
    // error の型は unknown
    console.error('Database error:', error);
    return null;
  }
}

エラーオブジェクトの型がunknownであるため、エラーの詳細な情報を安全に扱うことが困難です。

typescript// エラー型の問題例
try {
  const result = riskyOperation();
} catch (error) {
  // error は unknown 型
  console.log(error.message); // TypeScript エラー: Object is of type 'unknown'

  // 型アサーションが必要
  if (error instanceof Error) {
    console.log(error.message); // OK
  }
}

関数型プログラミングのエラーハンドリング手法

関数型プログラミングでは、エラーを例外として扱うのではなく、値として表現するアプローチが採用されています。

この手法により、エラーも通常の値と同様に型システムで管理できるようになります。

mermaidflowchart LR
  input[入力値] --> func[関数]
  func --> success[Success<T>]
  func --> error[Error<E>]
  success --> result[正常な結果]
  error --> handling[エラー処理]

図で理解できる要点:

  • エラーは例外ではなく戻り値として表現される
  • 成功とエラーの両方が型として定義される
  • 関数の戻り値の型で成功・失敗が明確になる

Haskell の Maybe 型や Rust の Result 型など、多くの関数型言語でこのパターンが採用されています。

typescript// 関数型プログラミングのアプローチ(概念)
type Maybe<T> = T | null;
type Result<T, E> =
  | { success: true; data: T }
  | { success: false; error: E };

function safeDivide(
  a: number,
  b: number
): Result<number, string> {
  if (b === 0) {
    return { success: false, error: 'Division by zero' };
  }
  return { success: true, data: a / b };
}

このアプローチにより、エラーハンドリングが型システムに組み込まれ、コンパイル時にエラー処理の漏れを検出できるようになります。

課題

try-catch 文による型安全性の欠如

従来のtry-catch文では、キャッチされる例外の型がunknownとなるため、型安全性が保証されません。

typescript// try-catch での型安全性の問題
async function fetchUserProfile(userId: string) {
  try {
    const response = await fetch(`/api/users/${userId}`);
    const user = await response.json();
    return user;
  } catch (error) {
    // error の型は unknown
    // エラーの詳細を安全に取得できない
    if (error instanceof Error) {
      console.error('Fetch error:', error.message);
    } else {
      console.error('Unknown error occurred');
    }
    return null;
  }
}

このパターンでは、エラーの種類や詳細な情報を型安全に処理することが困難です。また、複数の異なるエラータイプが発生する可能性がある場合、それらを区別して処理することも難しくなります。

エラーの種類が実行時まで分からない問題

複雑なアプリケーションでは、同じ関数から複数の異なるエラータイプが発生する可能性があります。

typescript// 複数のエラータイプが発生する可能性がある関数
async function processUserData(userId: string) {
  try {
    // ネットワークエラーの可能性
    const response = await fetch(`/api/users/${userId}`);

    if (!response.ok) {
      // HTTP エラーの可能性
      throw new Error(
        `HTTP ${response.status}: ${response.statusText}`
      );
    }

    const userData = await response.json();

    // バリデーションエラーの可能性
    if (!isValidUser(userData)) {
      throw new Error('Invalid user data format');
    }

    // データベースエラーの可能性
    await saveUserToDatabase(userData);

    return userData;
  } catch (error) {
    // どのエラーが発生したかコンパイル時に判断できない
    console.error('Processing failed:', error);
    throw error;
  }
}

このような状況では、エラーの種類に応じた適切な処理を実装するのが困難になります。

mermaidflowchart TD
  start[関数開始] --> network[ネットワーク処理]
  network --> networkError[NetworkError]
  network --> http[HTTP処理]
  http --> httpError[HTTPError]
  http --> validation[バリデーション]
  validation --> validationError[ValidationError]
  validation --> database[データベース処理]
  database --> dbError[DatabaseError]
  database --> success[成功]

  networkError --> catch[catch文]
  httpError --> catch
  validationError --> catch
  dbError --> catch

  catch --> unknown[unknown型のエラー]

図で理解できる要点:

  • 複数の異なるエラータイプが同じ catch 文で処理される
  • エラーの種類をコンパイル時に判断できない
  • 適切なエラー処理の実装が困難になる

エラーハンドリングの一貫性不足

チーム開発では、開発者によってエラーハンドリングの方法がばらつき、一貫性を保つのが困難です。

typescript// 開発者Aのエラーハンドリング
function fetchDataA(id: string) {
  try {
    return apiClient.getData(id);
  } catch (error) {
    console.error('Error in fetchDataA:', error);
    return null;
  }
}

// 開発者Bのエラーハンドリング
function fetchDataB(id: string) {
  try {
    return apiClient.getData(id);
  } catch (error) {
    throw new Error(`Failed to fetch data for ID: ${id}`);
  }
}

// 開発者Cのエラーハンドリング
async function fetchDataC(id: string) {
  try {
    return await apiClient.getData(id);
  } catch (error) {
    return { error: true, message: 'Data fetch failed' };
  }
}

このような一貫性の欠如は、以下の問題を引き起こします:

問題説明影響
1戻り値の型が統一されない呼び出し側での処理が複雑になる
2エラー情報の形式がばらつくデバッグとログ解析が困難
3例外の伝播方法が一定しない予期しないアプリケーション停止

これらの課題を解決するために、より統一されたエラーハンドリングのアプローチが必要となります。

解決策

Result 型パターンの概念

Result 型パターンは、関数型プログラミングから生まれたエラーハンドリング手法で、処理の結果を「成功」または「失敗」として明示的に型で表現します。

Result 型の基本的な構造は次のようになります:

typescript// Result型の基本定義
type Result<T, E> = Success<T> | Failure<E>;

interface Success<T> {
  readonly success: true;
  readonly data: T;
}

interface Failure<E> {
  readonly success: false;
  readonly error: E;
}

この型定義により、関数の戻り値として成功時のデータと失敗時のエラー情報の両方を型安全に扱えるようになります。

typescript// Result型を使用した関数の例
function divide(
  a: number,
  b: number
): Result<number, string> {
  if (b === 0) {
    return {
      success: false,
      error: 'Division by zero is not allowed',
    };
  }
  return { success: true, data: a / b };
}

// 使用例
const result = divide(10, 2);
if (result.success) {
  console.log('Result:', result.data); // 型安全にdataにアクセス
} else {
  console.error('Error:', result.error); // 型安全にerrorにアクセス
}

Result 型パターンの主な利点は以下のとおりです:

mermaidflowchart LR
  function[関数] --> result[Result型]
  result --> success[Success<T>]
  result --> failure[Failure<E>]
  success --> data[型安全なデータ]
  failure --> error[型安全なエラー]
  data --> handling1[成功時の処理]
  error --> handling2[エラー時の処理]

図で理解できる要点:

  • 成功と失敗が明確に型で区別される
  • コンパイル時にエラーハンドリングの漏れを検出できる
  • エラー情報も型安全に扱える

Neverthrow ライブラリの特徴

Neverthrow は、TypeScript で Result 型パターンを実装するための軽量なライブラリです。Rust の Result 型にインスパイアされており、型安全なエラーハンドリングを実現します。

まず、Neverthrow をプロジェクトにインストールしましょう:

bashyarn add neverthrow

Neverthrow の基本的な API は直感的で使いやすく設計されています:

typescriptimport { Result, ok, err } from 'neverthrow';

// 成功を表すResult
function createSuccess(): Result<string, never> {
  return ok('Operation completed successfully');
}

// 失敗を表すResult
function createError(): Result<never, string> {
  return err('Something went wrong');
}

Neverthrow は豊富なメソッドを提供しており、関数型プログラミングのパターンを活用できます:

typescript// map: 成功値を変換
const result = ok(5)
  .map((x) => x * 2)
  .map((x) => x.toString());

// mapErr: エラー値を変換
const errorResult = err('network error').mapErr(
  (e) => `Failed: ${e}`
);

// andThen: 連鎖的な処理(flatMap相当)
const chainedResult = ok(10).andThen((x) =>
  x > 0 ? ok(x * 2) : err('Negative number')
);

型安全なエラーハンドリングの実現方法

Neverthrow を使用することで、従来のtry-catch文では実現できない型安全なエラーハンドリングが可能になります。

エラータイプを明示的に定義することで、どのようなエラーが発生する可能性があるかをコンパイル時に把握できます:

typescript// エラータイプの定義
type NetworkError = {
  type: 'NetworkError';
  message: string;
};
type ParseError = { type: 'ParseError'; details: string };
type ValidationError = {
  type: 'ValidationError';
  field: string;
};

// 型安全なAPI呼び出し関数
async function fetchUser(
  id: string
): Promise<Result<User, NetworkError | ParseError>> {
  try {
    const response = await fetch(`/api/users/${id}`);

    if (!response.ok) {
      return err({
        type: 'NetworkError',
        message: `HTTP ${response.status}: ${response.statusText}`,
      });
    }

    const userData = await response.json();
    return ok(userData as User);
  } catch (error) {
    return err({
      type: 'ParseError',
      details: 'Failed to parse JSON response',
    });
  }
}

この実装により、関数の呼び出し側では発生する可能性のあるエラータイプを事前に知ることができ、適切な処理を実装できます:

typescript// 型安全なエラーハンドリング
async function handleUserFetch(userId: string) {
  const result = await fetchUser(userId);

  if (result.isOk()) {
    const user = result.value; // User型として安全にアクセス
    console.log('User fetched:', user.name);
  } else {
    const error = result.error; // NetworkError | ParseError として型安全

    switch (error.type) {
      case 'NetworkError':
        console.error('Network issue:', error.message);
        // ネットワークエラー固有の処理
        break;
      case 'ParseError':
        console.error('Parse issue:', error.details);
        // パースエラー固有の処理
        break;
    }
  }
}

このアプローチにより、エラーハンドリングがコンパイル時にチェックされ、処理し忘れたエラーケースがあれば TypeScript コンパイラが警告してくれます。

具体例

Result 型の基本実装

まず、シンプルな Result 型の実装から始めましょう。基本的な数値計算を例に、Result 型の動作を理解していきます。

typescriptimport { Result, ok, err } from 'neverthrow';

// 基本的なResult型を返す関数
function safeDivide(
  dividend: number,
  divisor: number
): Result<number, string> {
  if (divisor === 0) {
    return err('Division by zero is not allowed');
  }
  return ok(dividend / divisor);
}

// 使用例
const result1 = safeDivide(10, 2);
const result2 = safeDivide(10, 0);

console.log(result1); // Ok { value: 5 }
console.log(result2); // Err { error: "Division by zero is not allowed" }

Result 型の値を安全に取り出すには、isOk()メソッドやmatch()メソッドを使用します:

typescript// isOk()を使用したパターン
function processResult(result: Result<number, string>) {
  if (result.isOk()) {
    console.log('Success:', result.value);
  } else {
    console.error('Error:', result.error);
  }
}

// match()を使用したパターン(より関数型らしい書き方)
function processResultWithMatch(
  result: Result<number, string>
) {
  return result.match(
    (value) => `Success: ${value}`,
    (error) => `Error: ${error}`
  );
}

Neverthrow の導入と基本的な使い方

実際のアプリケーションで Neverthrow を活用する例を見てみましょう。ユーザー管理システムを想定したコードです。

まず、エラータイプを定義します:

typescript// エラータイプの定義
interface DatabaseError {
  type: 'DatabaseError';
  message: string;
  code?: string;
}

interface ValidationError {
  type: 'ValidationError';
  field: string;
  message: string;
}

interface NotFoundError {
  type: 'NotFoundError';
  resource: string;
  id: string;
}

type UserServiceError =
  | DatabaseError
  | ValidationError
  | NotFoundError;

次に、ユーザーデータの型を定義します:

typescriptinterface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}

interface CreateUserRequest {
  name: string;
  email: string;
}

基本的なバリデーション関数を実装します:

typescriptfunction validateEmail(
  email: string
): Result<string, ValidationError> {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

  if (!emailRegex.test(email)) {
    return err({
      type: 'ValidationError',
      field: 'email',
      message: 'Invalid email format',
    });
  }

  return ok(email);
}

function validateName(
  name: string
): Result<string, ValidationError> {
  if (name.trim().length === 0) {
    return err({
      type: 'ValidationError',
      field: 'name',
      message: 'Name cannot be empty',
    });
  }

  if (name.length < 2) {
    return err({
      type: 'ValidationError',
      field: 'name',
      message: 'Name must be at least 2 characters long',
    });
  }

  return ok(name.trim());
}

実際のアプリケーションでの活用事例

データベース操作をシミュレートしたユーザーサービスを実装してみましょう:

typescript// モックデータベース
const users: User[] = [];

class UserService {
  // ユーザー作成
  static async createUser(
    request: CreateUserRequest
  ): Promise<Result<User, UserServiceError>> {
    // 入力値のバリデーション
    const nameResult = validateName(request.name);
    if (nameResult.isErr()) {
      return err(nameResult.error);
    }

    const emailResult = validateEmail(request.email);
    if (emailResult.isErr()) {
      return err(emailResult.error);
    }

    // 重複チェック
    const existingUser = users.find(
      (u) => u.email === emailResult.value
    );
    if (existingUser) {
      return err({
        type: 'ValidationError',
        field: 'email',
        message: 'Email already exists',
      });
    }

    // ユーザー作成(データベース操作のシミュレーション)
    try {
      const newUser: User = {
        id: generateId(),
        name: nameResult.value,
        email: emailResult.value,
        createdAt: new Date(),
      };

      users.push(newUser);
      return ok(newUser);
    } catch (error) {
      return err({
        type: 'DatabaseError',
        message: 'Failed to create user',
        code: 'CREATE_FAILED',
      });
    }
  }

  // ユーザー取得
  static async getUserById(
    id: string
  ): Promise<Result<User, UserServiceError>> {
    try {
      const user = users.find((u) => u.id === id);

      if (!user) {
        return err({
          type: 'NotFoundError',
          resource: 'User',
          id: id,
        });
      }

      return ok(user);
    } catch (error) {
      return err({
        type: 'DatabaseError',
        message: 'Failed to fetch user',
        code: 'FETCH_FAILED',
      });
    }
  }
}

// ヘルパー関数
function generateId(): string {
  return Math.random().toString(36).substr(2, 9);
}

サービスの使用例を示します:

typescript// ユーザー作成の例
async function createUserExample() {
  const userRequest: CreateUserRequest = {
    name: 'John Doe',
    email: 'john@example.com',
  };

  const result = await UserService.createUser(userRequest);

  // match()を使用したパターンマッチング
  const message = result.match(
    (user) =>
      `User created successfully: ${user.name} (${user.id})`,
    (error) => {
      switch (error.type) {
        case 'ValidationError':
          return `Validation error in ${error.field}: ${error.message}`;
        case 'DatabaseError':
          return `Database error: ${error.message}`;
        default:
          return 'Unknown error occurred';
      }
    }
  );

  console.log(message);
}

複数のエラー処理パターンの比較

従来のtry-catch文と Neverthrow を使用した Result 型パターンを比較してみましょう:

typescript// 従来のtry-catch文を使用した実装
async function fetchUserProfileTraditional(
  userId: string
): Promise<any> {
  try {
    const userResult = await fetch(`/api/users/${userId}`);
    if (!userResult.ok) {
      throw new Error(
        `Failed to fetch user: ${userResult.statusText}`
      );
    }

    const user = await userResult.json();

    const profileResult = await fetch(
      `/api/profiles/${userId}`
    );
    if (!profileResult.ok) {
      throw new Error(
        `Failed to fetch profile: ${profileResult.statusText}`
      );
    }

    const profile = await profileResult.json();

    return { user, profile };
  } catch (error) {
    // エラーの型が不明
    console.error('Error fetching user profile:', error);
    throw error; // エラーを再スロー
  }
}

同じ機能を Neverthrow で実装した場合:

typescript// Result型を使用した実装
async function fetchUserProfileResult(
  userId: string
): Promise<Result<UserProfile, FetchError>> {
  const userResult = await fetchUser(userId);
  if (userResult.isErr()) {
    return err(userResult.error);
  }

  const profileResult = await fetchProfile(userId);
  if (profileResult.isErr()) {
    return err(profileResult.error);
  }

  return ok({
    user: userResult.value,
    profile: profileResult.value,
  });
}

// より関数型らしい書き方(andThen を使用)
async function fetchUserProfileFunctional(
  userId: string
): Promise<Result<UserProfile, FetchError>> {
  return (await fetchUser(userId)).andThen(async (user) => {
    return (await fetchProfile(userId)).map((profile) => ({
      user,
      profile,
    }));
  });
}

比較表:

観点try-catch 文Result 型
型安全性エラー型がunknownエラー型が明確
エラーハンドリング実行時にのみ判明コンパイル時にチェック
関数の戻り値成功時のみ考慮成功・失敗の両方を明示
エラーの伝播例外として伝播値として伝播
可読性処理とエラーハンドリングが分離処理の流れが明確

このように、Result 型を使用することで、より予測可能で保守性の高いコードを書くことができます。

まとめ

Result 型と Neverthrow の利点

Result 型と Neverthrow ライブラリを活用することで、従来のエラーハンドリングでは実現できない多くの利点を得られます。

型安全性の向上が最大の利点です。エラー情報が型として定義されるため、コンパイル時にエラーハンドリングの漏れを検出できます。これにより、実行時エラーの発生を大幅に減らすことができます。

予測可能な動作も重要な利点です。関数の戻り値の型を見るだけで、どのようなエラーが発生する可能性があるかを把握できます。

typescript// 関数のシグネチャだけで発生可能なエラーが明確
function processPayment(
  amount: number
): Result<PaymentResult, PaymentError | ValidationError> {
  // 実装...
}

エラーハンドリングの一貫性も確保されます。チーム全体で同じパターンを使用することで、コードの可読性と保守性が向上します。

mermaidflowchart TD
  traditional[従来のエラーハンドリング] --> issues[課題]
  issues --> unknown[エラー型がunknown]
  issues --> runtime[実行時まで分からない]
  issues --> inconsistent[一貫性がない]

  result[Result型パターン] --> benefits[利点]
  benefits --> typesafe[型安全性]
  benefits --> predictable[予測可能性]
  benefits --> consistent[一貫性]

  typesafe --> quality[コード品質向上]
  predictable --> quality
  consistent --> quality

図で理解できる要点:

  • 従来手法の課題が明確に解決される
  • 複数の利点が組み合わさってコード品質が向上する
  • 型安全性が他の利点の基盤となる

採用時の考慮点

Result 型パターンを導入する際には、いくつかの考慮点があります。

学習コストが最初の課題です。関数型プログラミングの概念に慣れていない開発者にとっては、新しい考え方を理解する時間が必要です。段階的な導入と適切な教育が重要になります。

既存コードとの共存も考慮が必要です。大規模なプロジェクトでは、すべてのエラーハンドリングを一度に移行するのは現実的ではありません。

typescript// 段階的移行の例:ラッパー関数を作成
function wrapPromise<T>(
  promise: Promise<T>
): Promise<Result<T, Error>> {
  return promise
    .then((value) => ok(value))
    .catch((error) => err(error));
}

// 既存のPromiseベースの関数をResult型に変換
async function migratedFunction() {
  const result = await wrapPromise(legacyAsyncFunction());
  return result;
}

パフォーマンスへの影響も軽微ながら存在します。Result 型オブジェクトの生成により、わずかなオーバーヘッドが発生しますが、実用上は問題になることはほとんどありません。

ライブラリの依存関係について、Neverthrow は軽量なライブラリですが、新しい依存関係を追加することになります。ただし、型安全性の向上によるメリットが依存関係のデメリットを大きく上回ります。

今後の活用方針

Result 型パターンの導入を成功させるための段階的なアプローチをご提案します。

フェーズ 1:新機能での導入 新しく開発する機能から段階的に Result 型を導入します。既存コードに影響を与えずに、チームメンバーがパターンに慣れることができます。

フェーズ 2:クリティカルな部分のリファクタリング エラーハンドリングが特に重要な部分(API 通信、データベース操作、バリデーション処理など)から順次移行します。

フェーズ 3:全体的な統一 チーム全体がパターンに慣れた段階で、コードベース全体の統一を図ります。

チーム内でのベストプラクティス策定も重要です:

項目ベストプラクティス
エラー型の命名一貫した命名規則(例:UserNotFoundError
エラーメッセージ具体的で実用的なメッセージ
エラーコードシステム全体で一意なコード
ドキュメントエラータイプの使用例を記載

Result 型パターンは、TypeScript での開発における型安全性を大幅に向上させる強力な手法です。適切な導入計画と継続的な改善により、より保守性の高いアプリケーションを構築できるでしょう。

このパターンを活用して、エラーに強く、予測可能な動作をするアプリケーションを開発していきましょう。

関連リンク