Fragments of verbose memory

冗長な記憶の断片 - Web技術のメモをほぼ毎日更新

Jan 22, 2026 - 日記

Zodで実装するNewtype Pattern: TypeScriptに欠けている型安全性を補う

Zodで実装するNewtype Pattern: TypeScriptに欠けている型安全性を補う cover image

TypeScript の型システムは構造的型付け(structural typing)です。つまり、構造が同じなら異なる型として扱われません。これが原因で、本来は区別すべき値を誤って混同してしまうバグが発生します。

Zod のbranded types(ブランド型)を使えば、この問題を解決できます。これはNewtype Pattern(newtypeパターン、同じプリミティブ型に「意味の違う型」を与えて取り違えを防ぐ手法)をTypeScriptで実現する、かなり実用的なアプローチです。

本記事では、branded typesの基本から実践的な使い方まで、実例とともに紹介します。

TypeScriptの構造的型付けの問題

TypeScriptでは、以下のコードがエラーなく通ってしまいます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
type UserId = string;
type PostId = string;

function getUser(userId: UserId) {
  // ユーザーを取得
}

function getPost(postId: PostId) {
  // 投稿を取得
}

const userId: UserId = "user_123";
const postId: PostId = "post_456";

getUser(postId); // ❌ 本来はエラーにしたいが、通ってしまう
getPost(userId); // ❌ これも通ってしまう

UserIdPostIdはどちらもstring型なので、TypeScriptは区別できません。実行時にデータベースから間違ったレコードを取得してしまう可能性があります。

Zodのbranded typesによる解決

Zodの.brand<>メソッドを使うと、型に「刻印」を付けて区別できるようになります。

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

const UserIdSchema = z.string().brand<"UserId">();
const PostIdSchema = z.string().brand<"PostId">();

type UserId = z.infer<typeof UserIdSchema>;
type PostId = z.infer<typeof PostIdSchema>;

function getUser(userId: UserId) {
  // ユーザーを取得
}

function getPost(postId: PostId) {
  // 投稿を取得
}

const userId = UserIdSchema.parse("user_123");
const postId = PostIdSchema.parse("post_456");

// @ts-expect-error PostId is not assignable to UserId
getUser(postId);

// @ts-expect-error UserId is not assignable to PostId
getPost(userId);

型の内部構造

branded typesは、内部的に以下のような型になります。

1
2
type UserId = string & z.$brand<"UserId">;
type PostId = string & z.$brand<"PostId">;

z.$brand<"UserId">という特殊な型が交差型(&)で追加されることで、同じstringでも異なる型として扱われます。

実用例1: メールアドレスとユーザー名の区別

フォームバリデーションで、メールアドレスとユーザー名を区別したい場合:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
const EmailSchema = z.email().brand<"Email">();
const UsernameSchema = z.string().min(3).max(20).brand<"Username">();

type Email = z.infer<typeof EmailSchema>;
type Username = z.infer<typeof UsernameSchema>;

function sendEmail(to: Email, subject: string) {
  // メール送信処理
}

function createUser(username: Username) {
  // ユーザー作成処理
}

const email = EmailSchema.parse("[email protected]");
const username = UsernameSchema.parse("john_doe");

sendEmail(email, "Welcome!"); // ✅ OK
// @ts-expect-error Username is not assignable to Email
sendEmail(username, "Welcome!");

実用例2: 通貨の区別

金額を扱う場合、通貨を間違えると重大な問題になります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
const USDSchema = z.number().positive().brand<"USD">();
const JPYSchema = z.number().int().positive().brand<"JPY">();

type USD = z.infer<typeof USDSchema>;
type JPY = z.infer<typeof JPYSchema>;

function chargeUSD(amount: USD) {
  console.log(`Charging $${amount}`);
}

function chargeJPY(amount: JPY) {
  console.log(`Charging ¥${amount}`);
}

const usd = USDSchema.parse(100.50);
const jpy = JPYSchema.parse(10000);

chargeUSD(usd); // ✅ OK
// @ts-expect-error JPY is not assignable to USD
chargeUSD(jpy);

実用例3: APIレスポンスの検証

外部APIから取得したデータを検証し、branded typeとして扱う例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const UserResponseSchema = z.object({
  id: z.string().brand<"UserId">(),
  email: z.email().brand<"Email">(),
  createdAt: z.iso.datetime({ offset: true }).brand<"ISODateTime">(),
});

type UserResponse = z.infer<typeof UserResponseSchema>;

async function fetchUser(userId: string): Promise<UserResponse> {
  const response = await fetch(`/api/users/${userId}`);
  const data = await response.json();
  
  // ランタイム検証 + branded typeの付与
  return UserResponseSchema.parse(data);
}

function displayUser(user: UserResponse) {
  console.log(`User ID: ${user.id}`);
  console.log(`Email: ${user.email}`);
}

const user = await fetchUser("user_123");
displayUser(user); // ✅ 型安全

branded typesの注意点

1. ランタイムには影響しない

branded typesは静的型チェックのみで機能します。実行時には通常の値として扱われます。

1
2
const userId = UserIdSchema.parse("user_123");
console.log(typeof userId); // => "string"

2. parseが必須

branded typeを得るには、必ずZodスキーマで.parse()または.safeParse()を実行する必要があります。

1
2
const userId: UserId = "user_123"; // ❌ エラー: stringはUserIdに代入できない
const userId = UserIdSchema.parse("user_123"); // ✅ OK

3. 入出力の方向を制御できる

Zod 4.2以降では、brandの方向を指定できます。

1
2
3
4
5
6
7
8
9
// デフォルト: 出力のみにbrandを付与
z.string().brand<"UserId">();
z.string().brand<"UserId", "in">(); // 同じ(Zod 4.2+)

// 入力のみにbrandを付与
z.string().brand<"UserId", "out">();

// 入出力両方にbrandを付与
z.string().brand<"UserId", "inout">();

Pydanticとの比較

PythonPydantic にも似た機能があります。

Pydantic(Python):

1
2
3
4
5
6
7
from pydantic import BaseModel, Field
from typing import Annotated

UserId = Annotated[str, Field(pattern=r'^user_\d+$')]

class User(BaseModel):
    id: UserId

Zod(TypeScript):

1
2
const UserIdSchema = z.string().regex(/^user_\d+$/).brand<"UserId">();
type UserId = z.infer<typeof UserIdSchema>;

どちらも「スキーマ定義から型を推論する」という思想は同じですが、Zodのbranded typesは型レベルでの区別に特化しています。

まとめ

Zodのbranded typesを使うことで、TypeScriptの構造的型付けの弱点を補い、以下のメリットが得られます。

  • 型の取り違えを防ぐ: UserIdPostIdを混同するバグを防止
  • ドメインモデルの表現: 通貨、メールアドレス、日時など、ドメイン固有の型を明確に表現
  • ランタイム検証と型安全性の両立: Zodのバリデーションと型推論を組み合わせて、安全なコードを書ける

特に大規模なプロジェクトや、外部APIとのやり取りが多いアプリケーションでは、branded typesの導入を検討する価値があります。

参考リンク