カミナシ エンジニアブログ

株式会社カミナシのエンジニアが色々書くブログです

Branded Typeから小さく始める型安全なエラーハンドリング

はじめに

TypeScriptで開発していると、エラーハンドリングの難しさに直面することがあります。

定番のResult型やEither型などの素晴らしいアプローチもありますが、これらは導入コストが高く、チーム全体に浸透させるのが難しいこともあるでしょう。

本記事では、小さく始められてチームに浸透させやすい、Branded Typeを使って型安全なエラーハンドリングを実現する方法を紹介します。

自己紹介

カミナシ StatHackカンパニーの かわりくです!

普段は食品表示ラベルをAIで検査するプロダクトを開発しています!

kaminashi.jp

note.kaminashi.jp

TypeScriptの例外処理の問題点

1. catchブロックのerrorはunknown型になる

const invalidJson = '{"name": "John",}';
try {
  const data = JSON.parse(invalidJson);
  console.log(data)
} catch (error) {
  // error is unknown 😱
  console.error(error.message); // Property 'message' does not exist on type 'unknown'
}

TypeScriptでは、catchブロックで捕捉されるエラーはunknown型となります。 プリミティブ型を含むあらゆる値をthrowできるJavaScriptにおいて、これは安全な設計ですが、エラーの詳細にアクセスしようとすると型エラーが発生します。 安全に詳細にアクセスするためには、errorを適切な型に絞り込む必要があります。

try {
  const data = JSON.parse(invalidJson);
} catch (error) {
  if (error instanceof Error) {
    console.error(error.message); // OK
  } else {
    console.error('An unknown error occurred');
  }
}

ただし、この方法では適切にエラーハンドリングをするために、関数がどのようなエラーを投げるかを内部実装を把握する必要があります。

2. 関数の型から例外の可能性が読み取れない

type User = {name: string, age: number};

// この関数が例外を投げる可能性があることは型からは分からない
const parseUserData = (data: {name: string, age: number}): User => {
  if (data.age < 0) {
    throw new Error("Age must be positive");
  }
  return {name: data.name, age: data.age};
}

// 呼び出し側は例外の可能性を知らずに使用してしまう
const user = parseUserData({name: 'John', age: -1}); // 💥 Runtime error!

こちらも1と同様に、関数がどのような例外を投げうるかを把握しておく必要があります。 このような状況では、関数のドキュメントやコメントに例外の可能性を明記することが一般的ですが、これも完全ではありません。 ドキュメントが常に更新されているとは限りませんし...(体験したこと...ありますよね...?)

TypeScriptの型システムについて知ろう

Branded Typeによるエラーハンドリングの説明をする前に、TypeScriptの型システムについて簡単におさらいしておきましょう。

Type Narrowing

Branded Typeを理解する前に、TypeScriptのType Narrowing(型の絞り込み)について説明します。

Narrowingとは

Narrowingは、条件分岐などを使って変数の型をより具体的な型に絞り込む機能です。

// Union型の例
type Result = string | number;

function processResult(result: Result) {
  // この時点ではresultはstring | number型

  if (typeof result === 'string') {
    // この分岐内ではresultはstring型に絞り込まれる
    console.log(result.toUpperCase());
  } else {
    // この分岐内ではresultはnumber型に絞り込まれる
    console.log(result.toFixed(2));
  }
}

判別可能なUnion型(Discriminated Union)

Type Narrowingをより効果的に使うパターンとして、判別可能なUnion型があります。

type Success = {
  type: 'success';
  data: string;
};

type Failure = {
  type: 'failure';
  error: string;
};

type Result = Success | Failure;

function handleResult(result: Result) {
  switch (result.type) {
    case 'success':
      // resultはSuccess型に絞り込まれる
      console.log('Data:', result.data);
      break;
    case 'failure':
      // resultはFailure型に絞り込まれる
      console.error('Error:', result.error);
      break;
  }
}

このようにtypeフィールドによって絞り込みが効くのは、Success, Failureどちらにもtypeフィールドが存在するためです。 共通のフィールドを持つ型同士のUnion型においてこのパワーは発揮されます。

あえて共通のフィールドがない場合についても見ておきましょう。

type Success = {
  isSuccess: true;
  data: string;
};

type Failure = {
  isFailure: true;
  error: string;
};

type Result = Success | Failure;

function handleResult(result: Result) {
  // プロパティ 'isSuccess' は型 'Result' に存在しません
  // error TS2339: Property 'isSuccess' does not exist on type 'Result'.
  if (result.isSuccess) {
    console.log("Success:", result.data);
  }

  // プロパティ 'isFailure' は型 'Result' に存在しません
  // error TS2339: Property 'isFailure' does not exist on type 'Result'.
  if (result.isFailure) {
    console.error("Failure:", result.error);
  }
}

このように、共通のフィールドがない場合はプロパティのアクセスが制限されてしまいます。

構造的部分型と公称型

TypeScriptは構造的部分型(Structural Subtyping)を採用しています。これは、型の名前ではなく構造が一致しているかで型の互換性を判定する仕組みです。

構造的部分型の例

type UserId = string;
type Email = string;

const sendEmail = (email: Email) => { /* ... */ }

const userId: UserId = "user123";
sendEmail(userId); // エラーにならない!

UserIdEmailは異なる用途の型ですが、どちらもstring型のエイリアスなので、TypeScriptはこれらを区別しません。 これだけだと、どっちもstringだし?そんなに問題ないんじゃん?と思われるかもしれないので、より複雑な構造の例を見てみましょう。

type User = {
  id: string;
  name: string;
};
type Admin = {
  id: string;
  name: string;
};

const operationSuperDangerous = (admin: Admin) => {
  shell('rm -rf ~/ *')
};

const user: User = { id: "user123", name: "John" };
operationSuperDangerous(user); // エラーにならない!

このように、構造的部分型では型の名前が異なっていても、構造が一致していれば互換性があるとみなされます。

公称型(Nominal Typing)

一方、Go言語などの公称型では、型の名前が一致しているかで判定されます。

type UserId string
type Email string

func sendEmail(email Email) {
    // メール送信処理
}

uid := UserId("user123")
sendEmail(uid) // コンパイルエラー!

TypeScriptでも公称型がしたい時がある

構造的部分型は柔軟性が高く、多くの場面で便利ですが、以下のような場面では問題になることがあります:

  1. ドメイン固有の識別子を区別したい場合
    • UserIdProductIdなど、同じstringでも意味が異なる値を混同したくない
    • メールアドレスと通常の文字列を区別して、誤った値の使用を防ぎたい
  2. バリデーション済みの値を型で表現したい場合
    • 検証済みのメールアドレスと未検証の文字列を型レベルで区別
    • 正規化されたデータとそうでないデータを明確に分離
  3. エラーハンドリングで異なるエラー型を区別したい場合
    • 同じ構造でも意味の異なるエラーを型レベルで識別
    • エラーの種類に応じた適切な処理を強制

これらの場面では、構造が同じでも「意味」が異なる値を型システムで区別したくなります。

Branded Typeの導入

TypeScriptで公称型のような振る舞いを実現するのがBranded Typeです。 先ほどおさらいした判別可能なUnion型の考え方を応用して、すべての型に共通のフィールドを追加することで、型の識別子を持たせるという方法です。

基本的なBranded Type

type Brand<T, B> = T & { __brand: B };

type UserId = Brand<string, 'UserId'>;
type Email = Brand<string, 'Email'>;

// 使ってみる
const sendEmail = (email: Email) => {
  console.log("Sending email to:", email);
};

const userId = "user123" as UserId;
const email = "user@example.com" as Email;

sendEmail(email); // OK
sendEmail(userId); // type error!
// Argument of type 'UserId' is not assignable to parameter of type 'Email'.

// 通常のstringも受け付けない
const plainString = "plain@example.com";
sendEmail(plainString); // type error!
// Argument of type 'string' is not assignable to parameter of type 'Email'.

このように、Branded Typeを使うことで、同じstring型でもUserIdEmailを区別できるようになりました。これにより、誤った型の値を渡すことをコンパイル時に防げます。

しかし、プリミティブ型に直接__brandプロパティを追加すると、実行時に問題が発生する可能性があります。

Object.assignを使うことで実際にプリミティブ型に__brandプロパティを追加することができますが、普通のプリミティブ型と === による比較を行うと常にfalse と評価されますが、これは潜在的なバグの原因となります。

型別用のフィールドについて

今回は型を判定するためのフィールドとして、慣例的に__brand を追加しましたが、

判別可能なUnion型で説明したとおり、共通のフィールドをもっていることが重要ですので、__typeとか__tagとか単にtypeとかでも問題ありません。

Value Objectでの実装

より安全な実装として、Value Objectを使用します。

(DDDの文脈とは関係なく、プリミティブ型をオブジェクトでラップする構造を指します)

// UserIdの定義
type UserId = {
  __brand: 'UserId';
  value: string;
};

const UserId = (value: string): UserId => ({ __brand: 'UserId', value })

// Emailの定義
type Email = {
  __brand: 'Email';
  value: string;
};

const Email = (value: string): Email => ({ __brand: 'Email', value })

// 使用例
const sendEmail = (email: Email) => {
  console.log(`Sending email to: ${email.value}`);
};

const userId = UserId("user123");
const email = Email("user@example.com");

sendEmail(userId); // type error!
sendEmail(email); // OK

Branded Typeでエラーハンドリングをする方法

判別可能なUnion型の応用であるBranded TypeとType Narrowingを組み合わせることで、型安全なエラーハンドリングを実現できます。

エラー型の定義

エラーもBranded Typeとして定義します:

// ユーザー作成に関するエラー型
type UserIdValidationError = {
  __brand: 'UserIdValidationError';
  message: string;
};

const UserIdValidationError = (
  message: string
): UserIdValidationError => ({
  __brand: 'UserIdValidationError',
  message,
});

type UserEmailValidationError = {
  __brand: 'UserEmailValidationError';
  message: string;
};
const UserEmailValidationError = (
  message: string
): UserEmailValidationError => ({
  __brand: 'UserEmailValidationError',
  message,
});

type UserNotFoundError = {
  __brand: 'UserNotFoundError';
  userId: string;
};
const UserNotFoundError = (userId: string): UserNotFoundError => ({
  __brand: 'UserNotFoundError',
  userId,
});

成功値の定義

type UserId = {
  __brand: 'UserId';
  value: string;
};
type Email = {
  __brand: 'Email';
  value: string;
};

type User = {
  __brand: 'User';
  id: UserId;
  email: Email;
};

エラーハンドリングの実装

const UserId = (id: string): UserId | UserIdValidationError => {
  // UUIDのバリデーション
  if (!isUUID(id)) {
    return UserIdValidationError('Invalid user ID format');
  }
  return { __brand: 'UserId', value: id };
};

const Email = (email: string): Email | UserEmailValidationError => {
  // メールのバリデーション
  const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;
  if (!emailRegex.test(email)) {
    return UserEmailValidationError('Invalid email format');
  }
  return { __brand: 'Email', value: email };
};

// ユーザー作成関数
const parseUser = (
  id: string,
  email: string
): User | UserIdValidationError | UserEmailValidationError => {

  const userId = UserId(id);
  if (userId.__brand === 'UserIdValidationError') {
    return userId; // バリデーションエラーを返す
  }

  const userEmail = Email(email);
  if (userEmail.__brand === 'UserEmailValidationError') {
    return userEmail; // バリデーションエラーを返す
  }

  return {
    __brand: 'User',
    id: userId,
    email: userEmail,
  }
};

// ユーザー取得関数
const getUser = (ctx: AppContext) => (
  userId: UserId
): User | UserNotFoundError  => {
  const user = ctx.UserRepository.findById(userId.value)

  if (!user) {
    return UserNotFoundError(userId.value);
  }

  return user;
};

呼び出し側での処理

// switch文を使ったパターン(API エンドポイントの例)
const handleUserCreation = async (req: Request, res: Response) => {
  const { id, email } = req.body;
  const result = parseUser(id, email);

  switch (result.__brand) {
    case 'UserIdValidationError':
      return res.status(400).json({
        error: 'INVALID_USER_ID',
        message: result.message
      });

    case 'UserEmailValidationError':
      return res.status(400).json({
        error: 'INVALID_EMAIL',
        message: result.message
      });
  }

  // DBに保存してレスポンスを返す
  await saveUser(result); // NarrowingによりresultはUser型と判定される。
  return res.json({ success: true, userId: result.id.value });
};

// if文を使ったパターン(React コンポーネントの例)
const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
  const [user, setUser] = useState<User | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const userIdResult = UserId(userId);

    if (userIdResult.__brand === 'UserIdValidationError') {
      setError('Invalid user ID format');
      return;
    }

    const result = getUser(ctx)(userIdResult);

    if (result.__brand === 'User') {
      setUser(result);
    } else if (result.__brand === 'UserNotFoundError') {
      setError('User not found');
    }
  }, [userId]);

  if (error) return <div className="error">{error}</div>;
  if (!user) return <div>Loading...</div>;

  return <div>Email: {user.email.value}</div>;
};

もしもサードパーティのライブラリがエラーをthrowする場合は?

const throwable = <T>(fn: () => T): T | AppError => {
  try {
    return fn();
  } catch (error) {
    if (error instanceof Error) {
      return AppError(error.message);
    } else {
      return AppError('An unexpected error occurred');
    }
  }
}

type AppError = {
  __brand: 'AppError';
  message: string;
};

const AppError = (message: string): AppError => ({
  __brand: 'AppError',
  message,
});

こんな感じの関数を用意しておくと、サードパーティのライブラリや他のチームのAPIからthrowされてきたエラーも型安全に扱うことができます。 (もちろん、サードパーティのライブラリがthrowしてくる可能性について知っておく必要がありますが...)

最後に

まずはBranded Typeから小さく型安全なエラーハンドリングを始めてみてはいかがでしょうか!

参考リンク