はじめに

TypeScriptを使っていても、エラーハンドリングの部分で「型安全性」が失われていることに気づいていますか。try-catchでキャッチしたエラーはunknown型となり、どのようなエラーが投げられるかは型システムから見えません。これはTypeScriptの大きな盲点の一つです。

本記事では、TypeScriptにおけるエラーハンドリングの課題を明らかにし、それを解決するための実践的なパターンを解説します。カスタムエラークラスによる型の明確化、Result型パターンによる関数型アプローチ、そしてneverthrowライブラリを使った型安全なエラーハンドリングまで、堅牢なアプリケーションを構築するための知識を体系的に学べます。

この記事を読み終えると、以下のことができるようになります。

  • TypeScriptにおけるtry-catchの型安全性の問題を理解できる
  • カスタムエラークラスを設計し、エラーを型で区別できる
  • Result型パターンでエラーを戻り値として明示的に扱える
  • neverthrowライブラリを使って型安全なエラーハンドリングを実装できる

実行環境・前提条件

前提知識

動作確認環境

ツール バージョン
Node.js 20.x以上
TypeScript 5.7以上
neverthrow 8.x
VS Code 最新版

本記事のサンプルコードは、TypeScript Playgroundで動作確認できます。neverthrowを使用するコードは、ローカル環境でのインストールが必要です。

期待される結果

本記事のコードを実行すると、以下の動作を確認できます。

  • カスタムエラークラスで型安全にエラーを判別できる
  • Result型によってエラーの可能性が型シグネチャに明示される
  • neverthrowを使ったチェーン処理でエラーを宣言的に扱える

TypeScriptにおけるtry-catchの問題点

catchブロックのerrorはunknown型

TypeScriptでは、catchブロックで受け取るエラーはunknown型です。これは型安全性の観点から正しい設計ですが、開発者にとっては扱いにくい側面があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
async function fetchUser(id: string) {
  try {
    const response = await fetch(`/api/users/${id}`);
    return await response.json();
  } catch (error) {
    // error は unknown 型
    // error.message とは書けない
    console.error(error);
    throw error;
  }
}

unknown型のエラーにアクセスするには、型ガードやアサーションが必要になります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
try {
  // 何らかの処理
} catch (error) {
  if (error instanceof Error) {
    console.error(error.message); // OK: string
    console.error(error.stack);   // OK: string | undefined
  } else {
    console.error("Unknown error:", error);
  }
}

関数シグネチャにエラー情報がない

TypeScriptの関数シグネチャには、その関数がどのようなエラーを投げる可能性があるかという情報がありません。Javaのthrows宣言のような仕組みがないのです。

1
2
3
4
5
6
7
// この関数がどんなエラーを投げるか、型からは分からない
function parseJSON(text: string): unknown {
  return JSON.parse(text); // SyntaxError を投げる可能性がある
}

// 呼び出し側は try-catch で囲むべきか判断できない
const data = parseJSON('{"name": "John"}');

これは以下の問題を引き起こします。

  1. エラーハンドリングの漏れ: 開発者がエラーの可能性に気づかない
  2. ドキュメント依存: JSDocコメントなど外部の情報に頼る必要がある
  3. リファクタリングの困難: エラーの種類が変わっても型チェックで検出できない

非同期処理でのエラー追跡の難しさ

Promiseのリジェクトも型情報を持ちません。

1
2
3
4
5
6
7
8
// Promise<User> という型だが、リジェクトの型は不明
async function getUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }
  return response.json();
}
flowchart TD
    A["async function getUser(): Promise&lt;User&gt;"] --> B{処理}
    B -->|成功| C["Promise.resolve(user)"]
    B -->|失敗| D["Promise.reject(???)"]
    C --> E["型: User"]
    D --> F["型: unknown(情報なし)"]

カスタムエラークラスによる型安全なエラー

カスタムエラークラスの基本

JavaScriptのErrorクラスを継承して、アプリケーション固有のエラークラスを作成できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class ValidationError extends Error {
  constructor(
    message: string,
    public readonly field: string,
    public readonly value: unknown
  ) {
    super(message);
    this.name = "ValidationError";
    // プロトタイプチェーンを正しく設定(TypeScriptのES5トランスパイル対策)
    Object.setPrototypeOf(this, ValidationError.prototype);
  }
}

class NotFoundError extends Error {
  constructor(
    public readonly resource: string,
    public readonly id: string
  ) {
    super(`${resource} with id ${id} not found`);
    this.name = "NotFoundError";
    Object.setPrototypeOf(this, NotFoundError.prototype);
  }
}

class NetworkError extends Error {
  constructor(
    message: string,
    public readonly statusCode?: number,
    public readonly cause?: Error
  ) {
    super(message);
    this.name = "NetworkError";
    Object.setPrototypeOf(this, NetworkError.prototype);
  }
}

instanceofによる型判別

カスタムエラークラスはinstanceofで判別でき、TypeScriptは自動的に型を絞り込みます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
function handleError(error: unknown): void {
  if (error instanceof ValidationError) {
    // この分岐では error は ValidationError 型
    console.error(`Validation failed for field "${error.field}": ${error.message}`);
    console.error(`Invalid value:`, error.value);
  } else if (error instanceof NotFoundError) {
    // この分岐では error は NotFoundError 型
    console.error(`${error.resource} not found: ${error.id}`);
  } else if (error instanceof NetworkError) {
    // この分岐では error は NetworkError 型
    console.error(`Network error (${error.statusCode}): ${error.message}`);
    if (error.cause) {
      console.error("Caused by:", error.cause);
    }
  } else if (error instanceof Error) {
    console.error("Unexpected error:", error.message);
  } else {
    console.error("Unknown error:", error);
  }
}

エラー階層の設計

大規模なアプリケーションでは、エラークラスを階層構造にすると管理しやすくなります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 基底クラス
abstract class AppError extends Error {
  abstract readonly code: string;
  readonly timestamp: Date;

  constructor(message: string) {
    super(message);
    this.timestamp = new Date();
    Object.setPrototypeOf(this, new.target.prototype);
  }
}

// ドメインエラー
class DomainError extends AppError {
  readonly code = "DOMAIN_ERROR";
}

class UserNotFoundError extends DomainError {
  readonly code = "USER_NOT_FOUND";
  constructor(public readonly userId: string) {
    super(`User ${userId} not found`);
  }
}

class InvalidEmailError extends DomainError {
  readonly code = "INVALID_EMAIL";
  constructor(public readonly email: string) {
    super(`Invalid email format: ${email}`);
  }
}

// インフラエラー
class InfrastructureError extends AppError {
  readonly code = "INFRASTRUCTURE_ERROR";
}

class DatabaseConnectionError extends InfrastructureError {
  readonly code = "DB_CONNECTION_ERROR";
  constructor(message: string, public readonly host: string) {
    super(message);
  }
}
classDiagram
    Error <|-- AppError
    AppError <|-- DomainError
    AppError <|-- InfrastructureError
    DomainError <|-- UserNotFoundError
    DomainError <|-- InvalidEmailError
    InfrastructureError <|-- DatabaseConnectionError
    
    class AppError {
        <<abstract>>
        +code: string
        +timestamp: Date
    }
    class DomainError {
        +code: "DOMAIN_ERROR"
    }
    class UserNotFoundError {
        +code: "USER_NOT_FOUND"
        +userId: string
    }

カスタムエラーの限界

カスタムエラークラスには改善点がありますが、根本的な問題は解決されません。

  • 関数シグネチャにエラー情報を含められない
  • throwはプログラムの制御フローを中断する
  • 複数のエラーを合成的に扱いにくい

これらの問題を解決するのがResult型パターンです。

Result型パターンの基本

Result型とは

Result型は、関数型プログラミング言語でよく使われるパターンで、処理の成功と失敗を戻り値として表現します。RustやHaskellなどの言語では標準的な手法です。

1
2
3
type Result<T, E> =
  | { success: true; value: T }
  | { success: false; error: E };

これにより、エラーの可能性が型シグネチャに明示されます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
function divide(a: number, b: number): Result<number, string> {
  if (b === 0) {
    return { success: false, error: "Division by zero" };
  }
  return { success: true, value: a / b };
}

const result = divide(10, 2);
if (result.success) {
  console.log(`Result: ${result.value}`); // result.value は number
} else {
  console.log(`Error: ${result.error}`);  // result.error は string
}

シンプルなResult型の実装

より実用的なResult型を実装してみます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 成功を表す型
type Ok<T> = {
  readonly _tag: "Ok";
  readonly value: T;
};

// 失敗を表す型
type Err<E> = {
  readonly _tag: "Err";
  readonly error: E;
};

// Result型(判別可能なユニオン型)
type Result<T, E> = Ok<T> | Err<E>;

// ヘルパー関数
function ok<T>(value: T): Ok<T> {
  return { _tag: "Ok", value };
}

function err<E>(error: E): Err<E> {
  return { _tag: "Err", error };
}

// 型ガード
function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
  return result._tag === "Ok";
}

function isErr<T, E>(result: Result<T, E>): result is Err<E> {
  return result._tag === "Err";
}

Result型を使った関数の実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
interface User {
  id: string;
  name: string;
  email: string;
}

interface ParseError {
  type: "PARSE_ERROR";
  message: string;
}

interface ValidationError {
  type: "VALIDATION_ERROR";
  field: string;
  message: string;
}

type UserParseError = ParseError | ValidationError;

function parseUser(json: string): Result<User, UserParseError> {
  // JSONパース
  let data: unknown;
  try {
    data = JSON.parse(json);
  } catch {
    return err({
      type: "PARSE_ERROR",
      message: "Invalid JSON format"
    });
  }

  // バリデーション
  if (typeof data !== "object" || data === null) {
    return err({
      type: "VALIDATION_ERROR",
      field: "root",
      message: "Expected an object"
    });
  }

  const obj = data as Record<string, unknown>;

  if (typeof obj.id !== "string") {
    return err({
      type: "VALIDATION_ERROR",
      field: "id",
      message: "id must be a string"
    });
  }

  if (typeof obj.name !== "string") {
    return err({
      type: "VALIDATION_ERROR",
      field: "name",
      message: "name must be a string"
    });
  }

  if (typeof obj.email !== "string") {
    return err({
      type: "VALIDATION_ERROR",
      field: "email",
      message: "email must be a string"
    });
  }

  return ok({
    id: obj.id,
    name: obj.name,
    email: obj.email
  });
}

Result型のメリット

flowchart LR
    subgraph "従来のtry-catch"
        A1["function(): T"]
        A2["throw Error"]
        A1 -.->|"暗黙的"| A2
    end
    
    subgraph "Result型"
        B1["function(): Result&lt;T, E&gt;"]
        B2["Ok&lt;T&gt; | Err&lt;E&gt;"]
        B1 -->|"明示的"| B2
    end
  1. 型シグネチャの明確化: エラーの可能性が戻り値の型に含まれる
  2. 強制的なエラーハンドリング: 戻り値を使うには成功・失敗の判定が必要
  3. 合成可能性: 複数のResultを組み合わせやすい
  4. 制御フローの明確化: throwによる非ローカルジャンプがない

neverthrowライブラリの活用

neverthrowとは

neverthrowは、TypeScript向けのResult型実装を提供するライブラリです。7,000以上のGitHubスターを獲得し、多くのプロジェクトで採用されています。

1
npm install neverthrow

基本的な使い方

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { ok, err, Result } from "neverthrow";

interface User {
  id: string;
  name: string;
}

interface FetchError {
  type: "NETWORK_ERROR" | "NOT_FOUND" | "PARSE_ERROR";
  message: string;
}

function fetchUser(id: string): Result<User, FetchError> {
  // 仮のロジック
  if (id === "invalid") {
    return err({
      type: "NOT_FOUND",
      message: `User ${id} not found`
    });
  }
  
  return ok({
    id,
    name: "John Doe"
  });
}

const result = fetchUser("123");

// isOk / isErr でチェック
if (result.isOk()) {
  console.log(`User: ${result.value.name}`);
} else {
  console.log(`Error: ${result.error.message}`);
}

mapとmapErrによる変換

mapメソッドで成功値を変換し、mapErrでエラーを変換できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { ok, err, Result } from "neverthrow";

interface RawUser {
  id: string;
  first_name: string;
  last_name: string;
}

interface User {
  id: string;
  fullName: string;
}

function fetchRawUser(id: string): Result<RawUser, string> {
  if (id === "invalid") {
    return err("User not found");
  }
  return ok({ id, first_name: "John", last_name: "Doe" });
}

// mapで成功値を変換
const userResult = fetchRawUser("123")
  .map((raw): User => ({
    id: raw.id,
    fullName: `${raw.first_name} ${raw.last_name}`
  }))
  .mapErr((error) => ({
    type: "FETCH_ERROR" as const,
    message: error
  }));

// userResult は Result<User, { type: "FETCH_ERROR"; message: string }> 型

andThenによるチェーン処理

複数のResult型を返す関数を連鎖させる場合はandThenを使います。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import { ok, err, Result } from "neverthrow";

interface User {
  id: string;
  name: string;
}

interface Order {
  id: string;
  userId: string;
  total: number;
}

interface AppError {
  code: string;
  message: string;
}

function getUser(id: string): Result<User, AppError> {
  if (id === "invalid") {
    return err({ code: "USER_NOT_FOUND", message: "User not found" });
  }
  return ok({ id, name: "John" });
}

function getOrders(userId: string): Result<Order[], AppError> {
  if (userId === "blocked") {
    return err({ code: "USER_BLOCKED", message: "User is blocked" });
  }
  return ok([
    { id: "order-1", userId, total: 100 },
    { id: "order-2", userId, total: 200 }
  ]);
}

function calculateTotal(orders: Order[]): Result<number, AppError> {
  const total = orders.reduce((sum, order) => sum + order.total, 0);
  if (total > 10000) {
    return err({ code: "LIMIT_EXCEEDED", message: "Order limit exceeded" });
  }
  return ok(total);
}

// チェーン処理
const totalResult = getUser("user-123")
  .andThen((user) => getOrders(user.id))
  .andThen((orders) => calculateTotal(orders));

// 結果の処理
totalResult.match(
  (total) => console.log(`Total: ${total}`),
  (error) => console.log(`Error [${error.code}]: ${error.message}`)
);
flowchart TD
    A["getUser('user-123')"] -->|Ok| B["getOrders(user.id)"]
    A -->|Err| E["Error: USER_NOT_FOUND"]
    B -->|Ok| C["calculateTotal(orders)"]
    B -->|Err| F["Error: USER_BLOCKED"]
    C -->|Ok| D["Success: total"]
    C -->|Err| G["Error: LIMIT_EXCEEDED"]

matchによるパターンマッチング

matchメソッドで成功と失敗の両方のケースを処理できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import { ok, err, Result } from "neverthrow";

function divide(a: number, b: number): Result<number, string> {
  if (b === 0) {
    return err("Division by zero");
  }
  return ok(a / b);
}

// match で両方のケースを処理
const message = divide(10, 2).match(
  (value) => `Result: ${value}`,
  (error) => `Error: ${error}`
);

console.log(message); // "Result: 5"

ResultAsyncによる非同期処理

neverthrowは非同期処理用のResultAsyncも提供しています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import { okAsync, errAsync, ResultAsync, fromPromise } from "neverthrow";

interface User {
  id: string;
  name: string;
}

interface ApiError {
  code: string;
  message: string;
}

// Promise を ResultAsync に変換
function fetchUser(id: string): ResultAsync<User, ApiError> {
  return fromPromise(
    fetch(`/api/users/${id}`).then((res) => {
      if (!res.ok) {
        throw new Error(`HTTP ${res.status}`);
      }
      return res.json() as Promise<User>;
    }),
    (error): ApiError => ({
      code: "FETCH_ERROR",
      message: error instanceof Error ? error.message : "Unknown error"
    })
  );
}

// ResultAsync もチェーン可能
async function main() {
  const result = await fetchUser("123")
    .map((user) => user.name.toUpperCase())
    .mapErr((error) => `Failed: ${error.message}`);

  result.match(
    (name) => console.log(`User: ${name}`),
    (error) => console.error(error)
  );
}

main();

Result.fromThrowableでtry-catchをラップ

既存の例外を投げる関数をResult型に変換できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { Result } from "neverthrow";

interface ParseError {
  type: "PARSE_ERROR";
  message: string;
}

// JSON.parse を Result を返す関数に変換
const safeJsonParse = Result.fromThrowable(
  JSON.parse,
  (error): ParseError => ({
    type: "PARSE_ERROR",
    message: error instanceof Error ? error.message : "Unknown parse error"
  })
);

const result1 = safeJsonParse('{"name": "John"}');
// Result<unknown, ParseError>

const result2 = safeJsonParse("invalid json");
// Err({ type: "PARSE_ERROR", message: "..." })

result1.match(
  (data) => console.log("Parsed:", data),
  (error) => console.error("Error:", error.message)
);

Result.combineで複数のResultを合成

複数のResultを一度に処理する場合はResult.combineを使います。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import { ok, err, Result } from "neverthrow";

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

function validateName(name: string): Result<string, ValidationError> {
  if (name.length < 2) {
    return err({ field: "name", message: "Name must be at least 2 characters" });
  }
  return ok(name);
}

function validateEmail(email: string): Result<string, ValidationError> {
  if (!email.includes("@")) {
    return err({ field: "email", message: "Invalid email format" });
  }
  return ok(email);
}

function validateAge(age: number): Result<number, ValidationError> {
  if (age < 0 || age > 150) {
    return err({ field: "age", message: "Invalid age" });
  }
  return ok(age);
}

// 全てのバリデーションを実行
const combined = Result.combine([
  validateName("John"),
  validateEmail("john@example.com"),
  validateAge(30)
]);

// combined は Result<[string, string, number], ValidationError> 型
combined.match(
  ([name, email, age]) => {
    console.log(`Valid user: ${name}, ${email}, ${age}`);
  },
  (error) => {
    console.error(`Validation failed: ${error.field} - ${error.message}`);
  }
);

型安全なエラーハンドリングのベストプラクティス

エラー型の設計指針

エラー型は以下の点を考慮して設計します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 1. 判別可能なユニオン型を使う
type AppError =
  | { type: "VALIDATION_ERROR"; field: string; message: string }
  | { type: "NOT_FOUND"; resource: string; id: string }
  | { type: "NETWORK_ERROR"; statusCode: number; message: string }
  | { type: "UNAUTHORIZED"; message: string };

// 2. エラーコードは定数で管理
const ErrorCodes = {
  VALIDATION_ERROR: "VALIDATION_ERROR",
  NOT_FOUND: "NOT_FOUND",
  NETWORK_ERROR: "NETWORK_ERROR",
  UNAUTHORIZED: "UNAUTHORIZED"
} as const;

type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];

// 3. 基底インターフェースを定義
interface BaseError {
  type: ErrorCode;
  message: string;
  timestamp: Date;
}

レイヤーごとのエラー変換

アプリケーションの各レイヤーでエラーを適切に変換します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import { Result, ResultAsync, err, ok, fromPromise } from "neverthrow";

// インフラ層のエラー
type InfraError =
  | { type: "DB_ERROR"; message: string }
  | { type: "NETWORK_ERROR"; message: string };

// ドメイン層のエラー
type DomainError =
  | { type: "USER_NOT_FOUND"; userId: string }
  | { type: "INVALID_OPERATION"; message: string };

// プレゼンテーション層のエラー
type PresentationError = {
  code: string;
  message: string;
  statusCode: number;
};

// インフラ層
function fetchFromDatabase(id: string): ResultAsync<unknown, InfraError> {
  return fromPromise(
    Promise.resolve({ id, name: "John" }), // 仮のDB呼び出し
    (): InfraError => ({ type: "DB_ERROR", message: "Connection failed" })
  );
}

// ドメイン層(インフラエラーをドメインエラーに変換)
function getUser(id: string): ResultAsync<{ id: string; name: string }, DomainError> {
  return fetchFromDatabase(id)
    .mapErr((): DomainError => ({
      type: "USER_NOT_FOUND",
      userId: id
    }))
    .andThen((data) => {
      if (typeof data === "object" && data !== null && "name" in data) {
        return ok(data as { id: string; name: string });
      }
      return err<{ id: string; name: string }, DomainError>({
        type: "INVALID_OPERATION",
        message: "Invalid user data"
      });
    });
}

// プレゼンテーション層(ドメインエラーをAPIエラーに変換)
function toApiError(error: DomainError): PresentationError {
  switch (error.type) {
    case "USER_NOT_FOUND":
      return {
        code: "USER_NOT_FOUND",
        message: `User ${error.userId} not found`,
        statusCode: 404
      };
    case "INVALID_OPERATION":
      return {
        code: "BAD_REQUEST",
        message: error.message,
        statusCode: 400
      };
  }
}
flowchart TD
    subgraph "プレゼンテーション層"
        P1["PresentationError"]
        P2["statusCode, code, message"]
    end
    
    subgraph "ドメイン層"
        D1["DomainError"]
        D2["USER_NOT_FOUND, INVALID_OPERATION"]
    end
    
    subgraph "インフラ層"
        I1["InfraError"]
        I2["DB_ERROR, NETWORK_ERROR"]
    end
    
    I1 --> D1
    D1 --> P1

網羅性チェックによる安全性

never型を使って、すべてのエラーケースを処理していることを保証します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
type AppError =
  | { type: "VALIDATION_ERROR"; message: string }
  | { type: "NOT_FOUND"; message: string }
  | { type: "SERVER_ERROR"; message: string };

function handleError(error: AppError): string {
  switch (error.type) {
    case "VALIDATION_ERROR":
      return `Validation failed: ${error.message}`;
    case "NOT_FOUND":
      return `Not found: ${error.message}`;
    case "SERVER_ERROR":
      return `Server error: ${error.message}`;
    default:
      // 全てのケースを処理していれば、ここには到達しない
      const exhaustiveCheck: never = error;
      return exhaustiveCheck;
  }
}

新しいエラー型を追加した場合、switch文でそのケースを処理しないとコンパイルエラーになります。

safeTryによる簡潔な記述

neverthrowのsafeTryを使うと、ジェネレーター構文でより読みやすいコードが書けます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { ok, err, Result, safeTry } from "neverthrow";

function validateName(name: string): Result<string, string> {
  if (name.length < 2) return err("Name too short");
  return ok(name);
}

function validateAge(age: number): Result<number, string> {
  if (age < 0) return err("Invalid age");
  return ok(age);
}

function createUser(name: string, age: number): Result<{ name: string; age: number }, string> {
  return safeTry(function* () {
    const validName = yield* validateName(name);
    const validAge = yield* validateAge(age);
    
    return ok({ name: validName, age: validAge });
  });
}

const result = createUser("John", 30);
result.match(
  (user) => console.log(`Created: ${user.name}, ${user.age}`),
  (error) => console.error(`Error: ${error}`)
);

まとめ

本記事では、TypeScriptにおける型安全なエラーハンドリングについて解説しました。

学んだこと

  1. try-catchの問題点: catchブロックのunknown型、関数シグネチャにエラー情報がない問題
  2. カスタムエラークラス: instanceofによる型判別、エラー階層の設計
  3. Result型パターン: 成功と失敗を戻り値で表現する関数型アプローチ
  4. neverthrowライブラリ: mapandThenmatchによるチェーン処理、ResultAsyncによる非同期対応

使い分けの指針

手法 適した場面
try-catch + カスタムエラー 外部ライブラリとの境界、シンプルなエラー処理
Result型(自前実装) 軽量なプロジェクト、学習目的
neverthrow 本格的なアプリケーション、複雑なエラーフロー

型安全なエラーハンドリングを実践することで、エラーの可能性が型レベルで明示され、ハンドリングの漏れをコンパイル時に検出できます。これにより、実行時エラーを減らし、より堅牢なアプリケーションを構築できます。

参考リンク