
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); // ❌ これも通ってしまう
|
UserIdとPostIdはどちらも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との比較
Python
のPydantic
にも似た機能があります。
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の構造的型付けの弱点を補い、以下のメリットが得られます。
- 型の取り違えを防ぐ:
UserIdとPostIdを混同するバグを防止 - ドメインモデルの表現: 通貨、メールアドレス、日時など、ドメイン固有の型を明確に表現
- ランタイム検証と型安全性の両立: Zodのバリデーションと型推論を組み合わせて、安全なコードを書ける
特に大規模なプロジェクトや、外部APIとのやり取りが多いアプリケーションでは、branded typesの導入を検討する価値があります。
参考リンク