TypeScript完全ガイド Vol.3

型でバグを「不可能」にする設計パターン

型を「制約」として使うのをやめ、「設計ツール」として使え。


この記事で得られること

  • Branded Typesで意味的な型安全を実現する
  • Result型・neverthrowで例外を型安全に扱う
  • zodでスキーマと型の二重管理を廃止する
  • 依存性注入を型安全に設計する
  • State Machineを型で表現し、不正な状態遷移を不可能にする

Chapter 1: Branded Types 完全ガイド

1-1. 問題:構造が同じでも意味が違う

TypeScriptの構造的型付けは便利ですが、「形が同じなら互換」という特性が時として危険です。

function getUserById(userId: string) { /* ... */ }
function getPostById(postId: string) { /* ... */ }

const postId = "post_abc123";
getUserById(postId); // ✅ TypeScript は通す。しかし明らかにバグ。

userIdpostId はどちらも string ですが、意味が異なります。これをコンパイル時に防ぎたい。


1-2. Branded Typesの実装

// unique symbol を使ってブランドを作る
declare const __brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [__brand]: B };

// 各IDの型を定義
type UserId  = Brand<string, "UserId">;
type PostId  = Brand<string, "PostId">;
type OrderId = Brand<string, "OrderId">;

// コンストラクタ関数(バリデーション付き)
function UserId(id: string): UserId {
  if (!id.startsWith("user_")) throw new Error(`Invalid UserId: ${id}`);
  return id as UserId;
}

function PostId(id: string): PostId {
  if (!id.startsWith("post_")) throw new Error(`Invalid PostId: ${id}`);
  return id as PostId;
}

1-3. 使用例と効果

function getUserById(id: UserId): Promise<User> { /* ... */ }
function getPostById(id: PostId): Promise<Post> { /* ... */ }

const userId = UserId("user_abc123");
const postId = PostId("post_xyz789");

getUserById(userId); // ✅ OK
getPostById(postId); // ✅ OK
getUserById(postId); // ❌ コンパイルエラー!型が違う
getUserById("user_abc"); // ❌ コンパイルエラー!string は UserId ではない

実行時のオーバーヘッドはゼロです。ブランドはコンパイル後に消える型情報です。


1-4. 数値へのBranded Types

type Meters    = Brand<number, "Meters">;
type Kilograms = Brand<number, "Kilograms">;
type Seconds   = Brand<number, "Seconds">;

const distance = 100 as Meters;
const weight   = 75 as Kilograms;

// 単位を混在させるバグを防ぐ
function speed(distance: Meters, time: Seconds): number {
  return distance / time;
}

speed(distance, 10 as Seconds); // ✅
speed(weight, 10 as Seconds);   // ❌ Kilograms を Meters として渡せない

1-5. 検証済みデータのBranded Types

type Email   = Brand<string, "Email">;
type SafeHtml = Brand<string, "SafeHtml">;

function validateEmail(email: string): Email {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(email)) throw new Error("Invalid email");
  return email as Email;
}

function sanitizeHtml(html: string): SafeHtml {
  // XSS 対策の sanitize 処理...
  return sanitized as SafeHtml;
}

// DBには検証済みのEmailしか保存できない
function saveUser(email: Email) { /* ... */ }

const input = req.body.email; // string
saveUser(input); // ❌ 未検証の string は渡せない
saveUser(validateEmail(input)); // ✅ 検証後なら渡せる

Chapter 2: エラーハンドリングの型設計

2-1. 例外(try/catch)の問題

// TypeScript では catch の型が unknown(TS 4.0+)
try {
  const user = await fetchUser(id);
} catch (error) {
  // error は unknown 型
  // error.message と書けない(コンパイルエラー)

  if (error instanceof Error) {
    console.error(error.message); // ✅ ここで初めて Error と確定
  }
}

try/catch の問題は「この関数が何をthrowするか」が型に現れないことです。


2-2. Result型パターン

// Result型の定義
type Result<T, E = Error> =
  | { ok: true;  value: T }
  | { ok: false; error: E };

// ヘルパー
const ok  = <T>(value: T): Result<T, never>  => ({ ok: true,  value });
const err = <E>(error: E): Result<never, E>  => ({ ok: false, error });

// 使用例
type FetchError =
  | { kind: "NotFound";  id: string }
  | { kind: "Network";   message: string }
  | { kind: "Forbidden" };

async function fetchUser(id: UserId): Promise<Result<User, FetchError>> {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (res.status === 404) return err({ kind: "NotFound", id });
    if (res.status === 403) return err({ kind: "Forbidden" });
    return ok(await res.json());
  } catch (e) {
    return err({ kind: "Network", message: String(e) });
  }
}

// 呼び出し側でエラー処理が強制される
const result = await fetchUser(userId);
if (!result.ok) {
  switch (result.error.kind) {
    case "NotFound":  return redirect("/404");
    case "Forbidden": return redirect("/403");
    case "Network":   return showRetryDialog();
  }
}

// ここでは result.value が User と確定
const user = result.value;

2-3. neverthrow ライブラリを使う

Result型を自前で実装する代わりに、neverthrow を使うと関数的なAPIで扱えます。

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

function parseJSON<T>(text: string): ResultAsync<T, SyntaxError> {
  return ResultAsync.fromPromise(
    Promise.resolve(JSON.parse(text) as T),
    (e) => e as SyntaxError
  );
}

// メソッドチェーンでエラー処理を合成できる
const result = await parseJSON<ApiResponse>(responseText)
  .map(res => res.data)
  .mapErr(err => ({ kind: "ParseError" as const, cause: err }));

2-4. エラーの型設計のベストプラクティス

// ❌ エラーを string で表現しない
type BadResult = { ok: false; error: string };

// ✅ Discriminated Union でエラーを分類する
type AppError =
  | { kind: "ValidationError"; fields: Record<string, string[]> }
  | { kind: "NotFoundError";   resource: string; id: string }
  | { kind: "AuthError";       reason: "expired" | "invalid" }
  | { kind: "NetworkError";    statusCode: number };

// エラーの表示ロジックを型安全に書ける
function formatError(error: AppError): string {
  switch (error.kind) {
    case "ValidationError":
      return Object.entries(error.fields)
        .map(([f, msgs]) => `${f}: ${msgs.join(", ")}`)
        .join("\n");
    case "NotFoundError":
      return `${error.resource} (id: ${error.id}) が見つかりません`;
    case "AuthError":
      return error.reason === "expired" ? "セッションが切れました" : "認証に失敗しました";
    case "NetworkError":
      return `通信エラー (${error.statusCode})`;
  }
}

Chapter 3: zodで作る型駆動API設計

3-1. 「型の二重管理」問題

// ❌ 型定義とバリデーションロジックが別々に存在する
interface CreateUserInput {
  name: string;
  email: string;
  age: number;
}

// バリデーション関数を別途書く必要がある(型と乖離するリスク)
function validateCreateUserInput(input: unknown): input is CreateUserInput {
  return typeof input === "object" && input !== null
    && typeof (input as any).name === "string"
    // ...全プロパティを手動チェック...
}

3-2. zodによる一元管理

import { z } from "zod";

// スキーマが型定義を兼ねる
const CreateUserSchema = z.object({
  name:  z.string().min(1, "名前は必須です").max(50),
  email: z.string().email("メールアドレスの形式が不正です"),
  age:   z.number().int().min(0).max(150),
  role:  z.enum(["admin", "member", "guest"]).default("member"),
});

// スキーマから型を自動生成(二重管理ゼロ)
type CreateUserInput = z.infer<typeof CreateUserSchema>;

// UpdateスキーマはCreateスキーマから派生させる
const UpdateUserSchema = CreateUserSchema
  .partial()            // 全フィールドをオプショナルに
  .required({ name: true }); // ただし name は必須のまま

type UpdateUserInput = z.infer<typeof UpdateUserSchema>;

3-3. APIハンドラーへの適用

// Next.js App Router の Route Handler での使用例
export async function POST(req: Request) {
  const body = await req.json();

  // safeParse は例外を投げず Result 風のオブジェクトを返す
  const parsed = CreateUserSchema.safeParse(body);

  if (!parsed.success) {
    return Response.json(
      { errors: parsed.error.flatten().fieldErrors },
      { status: 422 }
    );
  }

  // parsed.data は CreateUserInput 型が保証されている
  const user = await db.users.create({
    data: {
      ...parsed.data,
      passwordHash: await hash("defaultPass"),
    },
  });

  return Response.json(user, { status: 201 });
}

3-4. 高度なzodパターン

// refine:カスタムバリデーション
const PasswordSchema = z
  .string()
  .min(8)
  .refine(pw => /[A-Z]/.test(pw), "大文字を含めてください")
  .refine(pw => /[0-9]/.test(pw), "数字を含めてください");

// superRefine:複数フィールドにまたがるバリデーション
const SignUpSchema = z
  .object({
    password:        z.string().min(8),
    confirmPassword: z.string(),
  })
  .superRefine(({ password, confirmPassword }, ctx) => {
    if (password !== confirmPassword) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "パスワードが一致しません",
        path: ["confirmPassword"],
      });
    }
  });

// transform:入力を別の型に変換
const DateStringSchema = z
  .string()
  .datetime()
  .transform(s => new Date(s)); // string → Date に変換

type ParsedDate = z.infer<typeof DateStringSchema>; // Date

Chapter 4: 依存性注入と型設計

4-1. なぜDIが必要か

// ❌ 直接依存すると、テストが難しくなる
class UserService {
  async getUser(id: UserId): Promise<User> {
    // DB に直接アクセスしている
    return db.users.findById(id); // db が何者かテストで差し替えられない
  }
}

4-2. interfaceで依存を抽象化する

// 依存先のインターフェースを定義
interface UserRepository {
  findById(id: UserId): Promise<User | null>;
  create(input: CreateUserInput): Promise<User>;
  update(id: UserId, input: UpdateUserInput): Promise<User>;
  delete(id: UserId): Promise<void>;
}

// UserService は interface に依存する(具体実装には依存しない)
class UserService {
  constructor(private readonly repo: UserRepository) {}

  async getUser(id: UserId): Promise<Result<User, "NotFound">> {
    const user = await this.repo.findById(id);
    if (!user) return err("NotFound");
    return ok(user);
  }
}

// 本番用の実装
class PrismaUserRepository implements UserRepository {
  async findById(id: UserId) {
    return prisma.user.findUnique({ where: { id } });
  }
  // ...
}

// テスト用のモック実装
class MockUserRepository implements UserRepository {
  private users: Map<string, User> = new Map();

  async findById(id: UserId) {
    return this.users.get(id) ?? null;
  }
  // ...
}

4-3. Genericsを使った汎用的なRepository

// 基底リポジトリの型を定義
interface Repository<T, CreateInput, UpdateInput> {
  findById(id: string): Promise<T | null>;
  findMany(filter?: Partial<T>): Promise<T[]>;
  create(input: CreateInput): Promise<T>;
  update(id: string, input: UpdateInput): Promise<T>;
  delete(id: string): Promise<void>;
}

// ユースケース層でGenericsを活用
class GenericService<T, C, U> {
  constructor(private readonly repo: Repository<T, C, U>) {}

  async findOrThrow(id: string): Promise<T> {
    const item = await this.repo.findById(id);
    if (!item) throw new Error(`Not found: ${id}`);
    return item;
  }
}

// 具体的なサービスはGenericsを埋めるだけ
type UserService = GenericService<User, CreateUserInput, UpdateUserInput>;

Chapter 5: State Machineを型で表現する

5-1. 状態管理の「型の罠」

// ❌ よくある壊れやすい状態管理
type OrderState = {
  status: "pending" | "paid" | "shipped" | "delivered" | "cancelled";
  paymentId?: string;
  trackingNumber?: string;
  cancelledAt?: Date;
  cancelReason?: string;
};

// status: "paid" なのに paymentId がない、という矛盾状態が作れる

5-2. Discriminated Unionで状態を排他的に定義

// ✅ 各状態が持つべきデータを型で強制する
type OrderState =
  | { status: "pending" }
  | { status: "paid";      paymentId: string }
  | { status: "shipped";   paymentId: string; trackingNumber: string }
  | { status: "delivered"; paymentId: string; trackingNumber: string; deliveredAt: Date }
  | { status: "cancelled"; cancelledAt: Date; cancelReason: string };

// 状態遷移関数:不正な遷移はコンパイルエラー
function payOrder(
  order: Extract<OrderState, { status: "pending" }>,
  paymentId: string
): Extract<OrderState, { status: "paid" }> {
  return { status: "paid", paymentId };
}

function shipOrder(
  order: Extract<OrderState, { status: "paid" }>,
  trackingNumber: string
): Extract<OrderState, { status: "shipped" }> {
  return { ...order, status: "shipped", trackingNumber };
}

// delivered 状態の注文はキャンセルできない(型で強制)
function cancelOrder(
  order: Extract<OrderState, { status: "pending" | "paid" }>,
  reason: string
): Extract<OrderState, { status: "cancelled" }> {
  return { status: "cancelled", cancelledAt: new Date(), cancelReason: reason };
}

5-3. XStateとの型連携

import { createMachine, assign } from "xstate";

// コンテキストの型
type OrderContext = {
  orderId: string;
  paymentId?: string;
  trackingNumber?: string;
};

// イベントの型
type OrderEvent =
  | { type: "PAY";  paymentId: string }
  | { type: "SHIP"; trackingNumber: string }
  | { type: "DELIVER" }
  | { type: "CANCEL"; reason: string };

const orderMachine = createMachine({
  types: {} as {
    context: OrderContext;
    events: OrderEvent;
  },
  id: "order",
  initial: "pending",
  context: { orderId: "" },
  states: {
    pending: {
      on: {
        PAY: {
          target: "paid",
          actions: assign({ paymentId: ({ event }) => event.paymentId }),
        },
        CANCEL: { target: "cancelled" },
      },
    },
    paid: {
      on: {
        SHIP: {
          target: "shipped",
          actions: assign({ trackingNumber: ({ event }) => event.trackingNumber }),
        },
        CANCEL: { target: "cancelled" },
      },
    },
    shipped: {
      on: { DELIVER: { target: "delivered" } },
    },
    delivered: { type: "final" },
    cancelled: { type: "final" },
  },
});

5-4. React での State Machine 活用

import { useMachine } from "@xstate/react";

function OrderPage({ orderId }: { orderId: string }) {
  const [state, send] = useMachine(orderMachine, {
    input: { orderId },
  });

  return (
    <div>
      {state.matches("pending") && (
        <button onClick={() => send({ type: "PAY", paymentId: "pay_123" })}>
          支払う
        </button>
      )}
      {state.matches("paid") && (
        <button onClick={() => send({ type: "SHIP", trackingNumber: "JPN001" })}>
          発送する
        </button>
      )}
      {state.matches("delivered") && <p>配送完了</p>}
    </div>
  );
}

まとめ:Vol.3 のチェックリスト

  • [ ] Branded Typesを実装して UserId と PostId を混同不可にできる
  • [ ] Result型を定義して、例外を型安全に扱える
  • [ ] zodのスキーマから型を生成し、二重管理を廃止できる
  • [ ] safeParse でAPIのバリデーションを実装できる
  • [ ] interfaceを使ったDIでサービスをテスト可能にできる
  • [ ] Discriminated Unionで不正な状態を型レベルで排除できる
  • [ ] Extract ユーティリティで状態遷移を型で強制できる

次回 Vol.4「ツール・エコシステム」 では、tsconfig・ビルドツール・型定義ファイル・型テストを解説します。


質問・フィードバックはコメントにてどうぞ。

この記事は役に立ちましたか?