T-CREATOR

<div />

TypeScriptでResult型の使い方を整理する Neverthrowで型安全なエラーハンドリング

2025年12月28日
TypeScriptでResult型の使い方を整理する Neverthrowで型安全なエラーハンドリング

TypeScriptでの型安全なエラーハンドリングに悩んでいませんか。try-catchではエラーの型がunknownになり、実行時まで何が起きるか分からない。そんな課題を解決するのがResult型とNeverthrowの使い方です。この記事では、例外に頼らず失敗を型で表現する実務的な使い方を、never型やユニオン型の活用とともに整理します。実際にプロダクション環境で採用した経験と、ハマったポイントも含めてお伝えします。

手法型安全性エラー型使い方の難易度実務での採用判断
try-catch×unknown小規模・プロトタイプ向き
Result型(Neverthrow)ユニオン型で明示中〜大規模・型安全重視向き

検証環境

  • OS: macOS Sequoia 15.2
  • Node.js: 24.12.0 LTS (Krypton)
  • TypeScript: 5.7.3
  • 主要パッケージ:
    • neverthrow: 8.2.0
  • 検証日: 2025 年 12 月 28 日

背景:TypeScriptのエラーハンドリングが抱える実務課題

この章では、TypeScriptでのエラーハンドリングがなぜ問題になるのか、その背景を理解します。

try-catchが解決できなかった型安全性の課題

TypeScriptを使っていても、従来のtry-catch文ではエラーの型が必ずunknownになります。これは言語仕様による制限で、どれだけ型定義を頑張ってもコンパイル時にエラーの種類を判別できません。

typescripttry {
  const data = await fetchUserData(userId);
  processData(data);
} catch (error) {
  // errorはunknown型
  console.log(error.message); // エラー: Object is of type 'unknown'
}

実務でこの問題に直面したのは、API通信エラーとバリデーションエラーを区別して処理したい場面でした。try-catchでは両方とも同じunknown型として扱われ、型アサーションやinstanceofチェックを毎回書く必要がありました。

つまずきポイント: unknown型のエラーを扱う際、型ガードを忘れるとランタイムエラーになる。特に複数人で開発していると、誰かが型チェックを忘れがちです。

実務で起きた型不明エラーによる障害

実際に経験した本番障害があります。外部APIの接続エラーをcatchした際、エラーオブジェクトの構造が想定と異なっていたため、ログに記録する処理で例外が発生しました。

typescripttry {
  const response = await externalApi.call();
} catch (error) {
  // errorがError型だと思い込んでいた
  logger.error(error.message); // 実際はstring型だった→undefined
}

この問題の根本原因は、エラーの型が不明なまま処理していたことです。TypeScriptの型システムを使っているのに、エラーハンドリングだけは型安全でない状態でした。

関数型プログラミングからの学び

RustやHaskellといった関数型言語では、エラーを例外として投げるのではなく、関数の戻り値として表現します。RustのResult<T, E>型やHaskellのEither型がその代表例です。

この考え方の利点は、「成功」と「失敗」の両方が型として明示されることです。関数のシグネチャを見れば、どんなエラーが起こりうるかが一目で分かります。

mermaidflowchart LR
  input["関数の入力"] --> process["処理"]
  process --> success["Ok<T><br/>成功値"]
  process --> failure["Err<E><br/>エラー値"]
  success --> okHandler["成功時の処理"]
  failure --> errHandler["エラー時の処理"]

この図は、Result型の基本的な流れを示しています。処理の結果が成功か失敗かで明確に分岐し、それぞれに型安全な値が渡されます。

つまずきポイント: 関数型の考え方に慣れていないと、「わざわざ戻り値でエラーを返す意味は?」と感じやすい。しかし型安全性のメリットは、一度経験すると手放せなくなります。

課題:従来の使い方では防げない事故

この章では、try-catchを使った従来の使い方で起きる具体的な問題を見ていきます。

unknown型エラーの扱いにくさ

TypeScript 4.0以降、catch節の変数は自動的にunknown型になります。これは型安全性を高めるための変更でしたが、逆に使いにくさも生まれました。

typescriptasync function updateUser(userId: string, data: UserData) {
  try {
    await database.update(userId, data);
  } catch (error) {
    // errorはunknown型なので、そのままでは何もできない
    if (error instanceof Error) {
      // 型ガードが必須
      console.error(error.message);
    } else if (typeof error === "string") {
      console.error(error);
    } else {
      console.error("Unknown error");
    }
  }
}

実務でハマったのは、サードパーティライブラリが独自のエラーオブジェクトを投げるケースでした。instanceof Errorでは拾えず、かといって型定義も不明確で、結局any型にキャストする羽目になりました。

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

複雑なビジネスロジックでは、複数の種類のエラーが発生する可能性があります。しかしtry-catchでは、どのエラーが起こりうるかをコンパイル時に知る方法がありません。

typescriptasync function processOrder(orderId: string) {
  try {
    // ネットワークエラーの可能性
    const order = await fetchOrder(orderId);

    // バリデーションエラーの可能性
    validateOrder(order);

    // 在庫不足エラーの可能性
    await reserveStock(order.items);

    // 決済エラーの可能性
    await processPayment(order.total);
  } catch (error) {
    // どのエラーが起きたかは実行時にしか分からない
    // エラーハンドリングの漏れもコンパイラは検出できない
  }
}

このコードの問題は、4種類のエラーが起こりうるのに、それが型情報として表現されていないことです。新しい開発者がこのコードを読んでも、どんなエラーに備えるべきか分かりません。

mermaidflowchart TD
  start["関数開始"] --> fetch["注文取得"]
  fetch --> fetchErr["NetworkError"]
  fetch --> validate["バリデーション"]
  validate --> validateErr["ValidationError"]
  validate --> reserve["在庫確保"]
  reserve --> stockErr["StockError"]
  reserve --> payment["決済処理"]
  payment --> paymentErr["PaymentError"]
  payment --> success["成功"]

  fetchErr --> catch["catch節"]
  validateErr --> catch
  stockErr --> catch
  paymentErr --> catch
  catch --> unknown["unknown型<br/>エラーの種類不明"]

上図のように、複数の異なるエラーが全て同じcatch節で処理され、型情報が失われます。

チーム開発での一貫性不足

実際のプロジェクトで問題になったのは、開発者ごとにエラーハンドリングの方法がバラバラだったことです。

typescript// 開発者A: nullを返す
function getUserA(id: string): User | null {
  try {
    return db.findUser(id);
  } catch {
    return null; // エラー情報が失われる
  }
}

// 開発者B: 例外を再スロー
function getUserB(id: string): User {
  try {
    return db.findUser(id);
  } catch (error) {
    throw new Error(`Failed to get user: ${id}`); // 元のエラーが隠される
  }
}

// 開発者C: undefinedを返す
function getUserC(id: string): User | undefined {
  try {
    return db.findUser(id);
  } catch {
    return undefined; // nullとの使い分けが不明確
  }
}

この一貫性のなさは、コードレビューやバグ修正を難しくします。どの関数がどんなエラーハンドリングをしているか、戻り値の型を見ただけでは分からないのです。

つまずきポイント: チームでコーディング規約を決めても、try-catchでは型システムで強制できない。結局は開発者の注意力に依存してしまいます。

解決策と判断:Result型とNeverthrowの使い方

この章では、Result型パターンとNeverthrowライブラリの使い方を、採用判断とともに解説します。

Result型パターンの概念

Result型は、処理の結果を「成功(Ok)」または「失敗(Err)」として明示的に表現する型です。TypeScriptではユニオン型を使って実現します。

typescripttype Result<T, E> = Ok<T> | Err<E>;

このシンプルな定義が、型安全なエラーハンドリングの基盤になります。関数がResult型を返すことで、「この処理は失敗する可能性がある」ことが型レベルで明示されます。

typescript// 従来の使い方
function divide(a: number, b: number): number {
  if (b === 0) throw new Error("Division by zero");
  return a / b;
}

// Result型を使った使い方
function safeDivide(a: number, b: number): Result<number, string> {
  if (b === 0) return err("Division by zero");
  return ok(a / b);
}

後者の使い方では、関数のシグネチャを見るだけで「この関数は失敗する可能性があり、エラーはstring型で返される」ことが分かります。

Neverthrowライブラリを選んだ理由

TypeScriptでResult型を実現するライブラリはいくつかあります。fp-ts、ts-results、そしてNeverthrowなどです。実務でNeverthrowを選んだ理由は以下の通りです。

ライブラリ学習コストTypeScript最適化非同期サポート採用判断
fp-ts関数型を本格導入する場合
ts-resultsシンプルさ重視の場合
Neverthrowバランス重視(採用)

Neverthrowは、RustのResult<T, E>にインスパイアされており、TypeScriptの型システムと相性が良いです。また、非同期処理を扱うResultAsyncクラスも提供されており、実務での使い勝手が優れています。

bashnpm install neverthrow

インストールも簡単で、依存関係もゼロです(2025年12月時点)。

ユニオン型による型安全な使い方

Neverthrowの真価は、複数のエラー型をユニオン型で表現できることです。

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

// エラー型を明示的に定義
type NetworkError = { type: "NetworkError"; statusCode: number };
type ValidationError = { type: "ValidationError"; field: string };
type ApiError = NetworkError | ValidationError;

// 関数のシグネチャで発生しうるエラーが明確
function fetchUser(id: string): Promise<Result<User, ApiError>> {
  // 実装
}

この使い方により、呼び出し側ではどんなエラーが起こりうるかを型情報として受け取れます。TypeScriptの型推論とswitch文を組み合わせれば、エラー処理の漏れをコンパイラが検出してくれます。

typescriptconst result = await fetchUser("123");

if (result.isErr()) {
  const error = result.error; // ApiError型

  switch (error.type) {
    case "NetworkError":
      console.error(`Network error: ${error.statusCode}`);
      break;
    case "ValidationError":
      console.error(`Validation error in field: ${error.field}`);
      break;
    // caseを書き忘れるとTypeScriptが警告してくれる
  }
}

つまずきポイント: ユニオン型のエラーをswitch文で処理する際、typeプロパティによる判別(Discriminated Union)を使うのがポイント。これを忘れると型の絞り込みができません。

never型の活用場面

TypeScriptのnever型は、「値が存在しない」ことを表す型です。Neverthrowでは、成功のみ、または失敗のみが起こる場合にnever型を使います。

typescript// 必ず成功する処理(エラーはnever型)
function alwaysSucceed(): Result<string, never> {
  return ok("Success");
}

// 必ず失敗する処理(成功値はnever型)
function alwaysFail(): Result<never, string> {
  return err("Failure");
}

実務でnever型が役立つのは、初期化処理や設定の読み込みなど、「失敗したらアプリが起動できない」ケースです。

typescript// 設定ファイルの読み込み(失敗は許容しない)
function loadConfig(): Result<Config, never> {
  const config = readConfigFile();
  return ok(config); // 失敗する場合はプロセスを終了する前提
}

never型を使うことで、「このコードパスではエラーハンドリングが不要」という意図を型で表現できます。

mermaidflowchart LR
  okNever["Ok<T, never><br/>必ず成功"] --> value["成功値のみ"]
  errNever["Err<never, E><br/>必ず失敗"] --> error["エラー値のみ"]
  both["Result<T, E><br/>成功も失敗もある"] --> okOrErr["成功値 or エラー値"]

上図のように、never型を使い分けることで処理のパターンを型で明示できます。

採用しなかった他の選択肢

Result型以外の選択肢も検討しました。

Option/Maybe型: nullやundefinedを型安全に扱える型ですが、「なぜ失敗したか」の情報が持てません。エラーの詳細が必要な実務では不十分でした。

例外の型定義: TypeScript 5.7では例外の型を指定する提案もありましたが、言語仕様には採用されませんでした。将来的には改善されるかもしれません。

最終的に、「エラー情報を保持しつつ型安全」というバランスで、Result型とNeverthrowを採用しました。

具体例:実務での使い方パターン

この章では、Neverthrowの具体的な使い方を、実務でよく使うパターンごとに解説します。

基本的な使い方:ok/errの生成

最もシンプルな使い方から始めましょう。Neverthrowでは、ok()err()関数でResult型の値を作ります。

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

function parseNumber(input: string): Result<number, string> {
  const num = Number(input);

  if (isNaN(num)) {
    return err(`"${input}" is not a valid number`);
  }

  return ok(num);
}

// 使い方
const result1 = parseNumber("42");
console.log(result1); // Ok { value: 42 }

const result2 = parseNumber("abc");
console.log(result2); // Err { error: '"abc" is not a valid number' }

Result型の値を取り出すには、isOk()isErr()で判定してからvalueerrorにアクセスします。

typescriptconst result = parseNumber("123");

if (result.isOk()) {
  console.log(`Parsed: ${result.value}`); // 型安全にアクセス可能
} else {
  console.error(`Error: ${result.error}`);
}

つまずきポイント: result.valueに直接アクセスしようとすると、TypeScriptが「Okかどうか確認していない」とエラーを出します。必ずisOk()で判定してからアクセスしましょう。

never型の実践的な使い方

実務でnever型を使うのは、「このパスではエラーが起きない」ことが保証されている場合です。

typescript// ハードコードされた値の取得(失敗しない)
function getAppVersion(): Result<string, never> {
  return ok("1.0.0");
}

// デフォルト値の取得(失敗しない)
function getDefaultSettings(): Result<Settings, never> {
  return ok({
    theme: "light",
    language: "ja",
  });
}

逆に、バリデーション専用の関数では成功値がnever型になることもあります。

typescript// バリデーションのみ(成功時は値を返さない)
function validateAge(age: number): Result<never, string> {
  if (age < 0) {
    return err("Age cannot be negative");
  }
  if (age > 150) {
    return err("Age is unrealistically high");
  }
  // バリデーション成功だが、値は返さない
  // この場合、関数自体がvoidを返すか、別の設計にするのが普通
}

ただし実務では、上記のようなバリデーション専用関数よりも、値を返す関数の方が使いやすいです。never型は「概念上存在しない」ケースで使うのがベストです。

ユニオン型による複数エラーの使い方

実務で最も役立つのが、複数のエラー型をユニオン型で扱う使い方です。

typescript// エラー型の定義
type NotFoundError = {
  type: "NotFound";
  resource: string;
  id: string;
};

type DatabaseError = {
  type: "Database";
  message: string;
  code: string;
};

type PermissionError = {
  type: "Permission";
  userId: string;
  action: string;
};

type UserServiceError = NotFoundError | DatabaseError | PermissionError;

// ユーザー取得関数
async function getUser(
  userId: string,
  requesterId: string,
): Promise<Result<User, UserServiceError>> {
  // 権限チェック
  if (!hasPermission(requesterId, "read:user")) {
    return err({
      type: "Permission",
      userId: requesterId,
      action: "read:user",
    });
  }

  // データベースアクセス
  try {
    const user = await db.users.findById(userId);

    if (!user) {
      return err({
        type: "NotFound",
        resource: "User",
        id: userId,
      });
    }

    return ok(user);
  } catch (error) {
    return err({
      type: "Database",
      message: error instanceof Error ? error.message : "Unknown error",
      code: "QUERY_FAILED",
    });
  }
}

このユニオン型の使い方により、呼び出し側では3種類のエラーを型安全に処理できます。

typescriptconst result = await getUser("user-123", "requester-456");

if (result.isErr()) {
  const error = result.error; // UserServiceError型

  switch (error.type) {
    case "NotFound":
      console.log(`${error.resource} with ID ${error.id} not found`);
      break;

    case "Database":
      console.error(`DB Error [${error.code}]: ${error.message}`);
      break;

    case "Permission":
      console.warn(`User ${error.userId} lacks permission: ${error.action}`);
      break;
  }
}

つまずきポイント: ユニオン型のエラーを定義する際、必ずtypeプロパティで判別可能にしましょう。これがないと、switch文での型の絞り込みができません。

関数チェーンの使い方:map/mapErr/andThen

Neverthrowの強力な機能が、関数型プログラミングスタイルでの処理のチェーンです。

map: 成功値の変換

map()は、成功値を別の値に変換します。エラーの場合はそのまま素通しします。

typescriptconst result = parseNumber("42")
  .map((n) => n * 2)
  .map((n) => `Result: ${n}`);

console.log(result); // Ok { value: "Result: 84" }

const errorResult = parseNumber("abc")
  .map((n) => n * 2)
  .map((n) => `Result: ${n}`);

console.log(errorResult); // Err { error: '"abc" is not a valid number' }

mapErr: エラー値の変換

mapErr()は、エラー値を別の型に変換します。成功値の場合はそのまま素通しします。

typescriptconst result = parseNumber("abc").mapErr((msg) => ({
  type: "ParseError" as const,
  message: msg,
  timestamp: new Date(),
}));

// エラーがオブジェクト型に変換される

andThen: 連鎖的な処理(flatMap相当)

andThen()は、成功値を使って次のResult型を返す処理を繋げます。いわゆるflatMapやbindに相当します。

typescriptfunction parseNumber(input: string): Result<number, string> {
  const num = Number(input);
  return isNaN(num) ? err("Not a number") : ok(num);
}

function validatePositive(num: number): Result<number, string> {
  return num > 0 ? ok(num) : err("Must be positive");
}

function divide10By(num: number): Result<number, string> {
  return num === 0 ? err("Division by zero") : ok(10 / num);
}

// チェーン処理
const result = parseNumber("5").andThen(validatePositive).andThen(divide10By);

console.log(result); // Ok { value: 2 }

途中でエラーが発生すると、以降の処理はスキップされます。

typescriptconst errorResult = parseNumber("-5")
  .andThen(validatePositive) // ここで失敗
  .andThen(divide10By); // 実行されない

console.log(errorResult); // Err { error: 'Must be positive' }

実務でのハマりポイント: mapandThenの使い分けに最初は戸惑います。ルールは簡単で、「次の処理がResult型を返すならandThen、普通の値を返すならmap」です。

typescript// OK: mapは普通の値を返す関数に使う
result.map((n) => n * 2);

// OK: andThenはResult型を返す関数に使う
result.andThen((n) => divide10By(n));

// NG: これだとResult<Result<number, E>, E>になってしまう
result.map((n) => divide10By(n));

非同期処理での使い方:ResultAsync

実務では非同期処理が多いため、NeverthrowのResultAsyncクラスが活躍します。

typescriptimport { ResultAsync, ok, err } from "neverthrow";

function fetchUser(id: string): ResultAsync<User, NetworkError> {
  return ResultAsync.fromPromise(
    fetch(`/api/users/${id}`).then((res) => {
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json();
    }),
    (error) => ({
      type: "NetworkError" as const,
      message: error instanceof Error ? error.message : "Unknown error",
    }),
  );
}

ResultAsyncもResult型と同じようにmapandThenなどが使えます。

typescriptconst result = await fetchUser("123")
  .map((user) => user.email)
  .map((email) => email.toLowerCase())
  .mapErr((error) => {
    console.error("Failed to fetch user:", error.message);
    return error;
  });

if (result.isOk()) {
  console.log(`Email: ${result.value}`);
}

複数の非同期処理を組み合わせる場合も、チェーンで繋げられます。

typescriptasync function getUserProfile(
  userId: string,
): Promise<Result<UserProfile, AppError>> {
  return await fetchUser(userId)
    .andThen((user) => fetchUserSettings(user.id))
    .andThen((settings) =>
      fetchUserPosts(userId).map((posts) => ({
        user: userId,
        settings,
        posts,
      })),
    );
}

つまずきポイント: ResultAsyncは内部的にPromiseを持っているので、最終的に値を取り出すにはawaitが必要です。これを忘れるとPromiseのまま扱ってしまい、型エラーになります。

実務でのエラーハンドリングパターン

実際のプロダクションコードでは、以下のようなパターンで使っています。

typescript// 1. エラー型の統一定義(types/errors.ts)
export type AppError =
  | { type: "Network"; statusCode: number; message: string }
  | { type: "Validation"; field: string; message: string }
  | { type: "NotFound"; resource: string; id: string }
  | { type: "Permission"; action: string }
  | { type: "Database"; code: string; message: string };

// 2. 各レイヤーでの使い方(services/user.ts)
export async function createUser(
  data: CreateUserInput,
): Promise<Result<User, AppError>> {
  // バリデーション
  const validated = validateUserInput(data);
  if (validated.isErr()) return validated;

  // データベース操作
  const created = await insertUser(validated.value);
  if (created.isErr()) return created;

  return ok(created.value);
}

// 3. コントローラーでの使い方(api/users.ts)
export async function handleCreateUser(req: Request, res: Response) {
  const result = await createUser(req.body);

  if (result.isErr()) {
    const error = result.error;

    switch (error.type) {
      case "Validation":
        return res
          .status(400)
          .json({ error: error.message, field: error.field });
      case "Permission":
        return res.status(403).json({ error: "Forbidden" });
      case "Database":
        return res.status(500).json({ error: "Internal server error" });
      default:
        return res.status(500).json({ error: "Unknown error" });
    }
  }

  return res.status(201).json(result.value);
}

このパターンにより、エラーハンドリングがレイヤーを超えて一貫性を保てます。

比較まとめ:try-catchとResult型の使い方

この章では、従来のtry-catchとResult型の使い方を、実務の観点で詳細に比較します。

型安全性の比較

観点try-catchResult型(Neverthrow)
エラーの型unknown(型不明)ユニオン型で明示
コンパイル時チェックなしエラー処理の漏れを検出
型推論効かない完全に効く
型ガードの必要性毎回必要不要(型が保証される)
リファクタリング安全性低(実行時に発覚)高(コンパイル時に発覚)

実務での体感として、Result型に移行してからリファクタリングが格段に楽になりました。エラー型を変更すると、TypeScriptが影響範囲を全て教えてくれるためです。

開発体験の比較

typescript// try-catch: エラーの種類が分からない
try {
  await processOrder(orderId);
} catch (error) {
  // ここでどう処理すべきか、型情報がないので分からない
  if (error instanceof OrderNotFoundError) {
    // ...
  } else if (error instanceof PaymentError) {
    // ...
  }
  // 他にどんなエラーがあるか、コードを読まないと分からない
}

// Result型: エラーの種類が型で分かる
const result = await processOrder(orderId);
if (result.isErr()) {
  const error = result.error; // OrderError型

  // switch文でエラー処理を書けば、漏れがあればTypeScriptが警告
  switch (error.type) {
    case "NotFound": // ...
    case "Payment": // ...
    case "Stock": // ...
    // caseを書き忘れるとコンパイルエラー
  }
}

Result型の使い方では、IDEの補完が効くため、どんなエラーがあるかを覚えていなくても開発できます。

パフォーマンスの比較

Result型はオブジェクトの生成が発生するため、理論上はtry-catchより遅いです。しかし実測してみると、その差はほとんど無視できるレベルでした。

typescript// 簡易ベンチマーク(100万回実行)
// try-catch: 約15ms
// Result型: 約18ms

実務のアプリケーションでは、ネットワークやデータベースのI/Oが支配的なため、この差は体感できません。

つまずきポイント: パフォーマンスを気にしてResult型を避けるのは早計です。まずは型安全性を優先し、ボトルネックが実測で確認されてから最適化しましょう。

向いているケース・向かないケース

Result型が向いているケース

  • 中〜大規模なアプリケーション
  • 複数人でのチーム開発
  • エラーの種類が多い処理
  • 長期間メンテナンスするコード
  • 型安全性を重視するプロジェクト

実際に導入したプロジェクトでは、API層とビジネスロジック層で大きな効果がありました。

try-catchが向いているケース

  • プロトタイプや小規模スクリプト
  • エラーの種類が単純な処理
  • 外部ライブラリとの境界(ラッパーでResult型に変換)
  • 学習コストを避けたい場合

全てをResult型にする必要はありません。適材適所で使い分けましょう。

mermaidflowchart TD
  start["エラーハンドリングの選択"] --> size{"プロジェクト規模"}
  size -->|小規模・プロトタイプ| trycatch["try-catch"]
  size -->|中〜大規模| errorTypes{"エラーの種類"}

  errorTypes -->|単純・1種類| trycatch
  errorTypes -->|複数・複雑| team{"チーム開発?"}

  team -->|個人開発| consider["Result型を検討"]
  team -->|チーム開発| result["Result型を推奨"]

  trycatch --> wrapper["必要に応じて<br/>Result型に変換"]
  consider --> result

上図のように、プロジェクトの特性に応じて判断するのが実務的です。

段階的導入の判断基準

Result型を既存プロジェクトに導入する際、一気に全てを変えるのは現実的ではありません。実際に採用したアプローチは以下の通りです。

フェーズ1: 新規機能から導入

新しく作る機能だけResult型を使います。既存コードには手を付けません。

typescript// 新規API(Result型)
export async function createNewFeature(): Promise<Result<Data, AppError>> {
  // Result型で実装
}

// 既存API(try-catch)はそのまま
export async function oldFeature(): Promise<Data> {
  // 変更しない
}

フェーズ2: 境界層でラッパー作成

外部ライブラリや既存コードをResult型でラップします。

typescript// 既存のPromiseベースの関数をResult型に変換
function wrapPromise<T>(promise: Promise<T>): ResultAsync<T, Error> {
  return ResultAsync.fromPromise(promise, (e) => e as Error);
}

// 使い方
const result = await wrapPromise(legacyAsyncFunction());

フェーズ3: クリティカルな部分をリファクタリング

エラーハンドリングが特に重要な部分(決済処理、認証など)を優先的に移行します。

この段階的なアプローチにより、チームの学習コストを抑えつつ、型安全性の恩恵を受けられました。

まとめ

Result型とNeverthrowの使い方は、TypeScriptでの型安全なエラーハンドリングを実現する有力な選択肢です。ただし、万能ではありません。

プロジェクトの規模や複雑さ、チームの経験に応じて判断してください。小規模なプロトタイプや、エラーの種類が単純な処理では、従来のtry-catchで十分なケースもあります。

一方で、中〜大規模なアプリケーション、複数人でのチーム開発、長期メンテナンスが前提のコードでは、Result型の型安全性が大きな価値を発揮します。実際にプロダクション環境で採用した結果、エラーハンドリングの漏れが減り、リファクタリングの安全性が向上しました。

導入を検討する際は、まず新規機能から小さく始め、チームで使い方を学びながら適用範囲を広げていくアプローチをお勧めします。never型やユニオン型の使い方に慣れれば、TypeScriptの型システムを最大限に活用できるはずです。

あなたのプロジェクトでも、型安全なエラーハンドリングの使い方を試してみてはいかがでしょうか。

関連リンク

著書

とあるクリエイター

フロントエンドエンジニア Next.js / React / TypeScript / Node.js / Docker / AI Coding

;