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 は通す。しかし明らかにバグ。
userId と postId はどちらも 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・ビルドツール・型定義ファイル・型テストを解説します。
質問・フィードバックはコメントにてどうぞ。
この記事は役に立ちましたか?