🛡️

Zod + Branded Typeで真の型安全へ

に公開
2

型安全、どうやって保証してる?

みなさんは普段どのように型の安全性を保証していますか?
TypeScriptを使っている方なら、型定義や型注釈、型推論で「型安全」を意識しているはずです。
しかし、実際のアプリケーション開発では「本当に型安全か?」と問われると、少し不安になることも多いのではないでしょうか。

Zodのスキーマ定義・型生成・データ検証

TypeScript界隈で人気の型安全ライブラリ「Zod」。
Zodを使えば、スキーマ定義・型生成・データ検証が一気通貫で行えます。

https://www.npmjs.com/package/zod

例えば「idは5桁かつ英数字のみ、nameは3文字以上15文字以下の文字列」としてUserSchemaを定義するとき、以下のようにスキーマ定義・型生成・データ検証ができます。

import { z } from "zod";

// スキーマ定義
const UserSchema = z.object({
  id: z.string().regex(/^[a-zA-Z0-9]{5}$/), // 5桁の英数字のみ
  name: z.string().min(3).max(15), // 3文字以上15文字以下の文字列
});

// スキーマ定義から型生成
type User = z.infer<typeof UserSchema>;

// parseメソッドで検証
const userNG: User = UserSchema.parse({ id: "hoge!@#", name: "yu" }); // => 形式違いでエラー
const userOK: User = UserSchema.parse({ id: "abc45", name: "Taro" }); // => OK

Zodだけでは型安全は不十分

Zodは強力ですが、実は「Zodだけ」では型安全が不十分なケースがあります。

たとえば、id: z.string()id: z.string().regex(/^[a-zA-Z0-9]{5}$/)でスキーマを定義しても、型レベルではどちらも任意のstringとして解釈されてしまいます。

import { z } from "zod";

// スキーマ定義
const UserSchema = z.object({
  id: z.string().regex(/^[a-zA-Z0-9]{5}$/), // 5桁の英数字のみ
  name: z.string().min(3).max(15), // 3文字以上15文字以下の文字列
});

// スキーマ定義から型生成
type User = z.infer<typeof UserSchema>;

// 直接代入
// 形式が違うのにエラーにならない!
const user: User = { id: "!abcde12345", name: "a" }

idnameもスキーマ定義に反した値ですが、型レベルで見るとどちらもstringなので、直接代入すると間違った値でも代入できてしまいます。

z.infer<typeof UserSchema>で生成したUser型は以下のようになります。

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

Branded Typeとは

この問題を解決するのが「Branded Type(以下、ブランド型)」です。
ブランド型は、同じ構造のデータをプロパティシンボルを使って「意味の違い」を型レベルで区別するための型または手法のことです。

https://effect.website/docs/code-style/branded-types/

Branded Typeの使い方

ブランド型の基本形は次の通りです。

type UserId = string & { readonly _brand: "user_id" };
type OrderId = string & { readonly _brand: "order_id" };

function createUserId(): UserId {
  // ここでID生成処理
  return id as UserId;
}

function createOrderId(): OrderId {
  // ここでID生成処理
  return id as OrderId;
}

const userId: UserId = createUserId(); // OK
const orderId: OrderId = createOrderId(); // OK

// ブランド型のおかげで型レベルで区別できる
const invalid: UserId = orderId; // 型エラー

このように、UserId型とOrderId型はどちらもstringですが、ブランド型を付与することで型レベルで区別でき、意図しない値の混入を防げます。

Zodではv3.18.0以降でbrandメソッドを使ってスキーマにブランド型を付与できます。

import { z } from "zod";

// スキーマ定義
const UserSchema = z
  .object({
    id: z.string().regex(/^[a-zA-Z0-9]{5}$/), // 5桁の英数字のみ
    name: z.string().min(3).max(15), // 3文字以上15文字以下の文字列
  })
  .brand<"User">(); // brand<任意の文字列>()

Zod + Branded Typeで真の型安全へ

Zodのスキーマ定義・型生成・parseメソッドにブランド型を組み合わせることで、真の型安全を実現できます。

import { z } from "zod";

// スキーマ定義
const UserSchema = z
  .object({
    id: z.string().regex(/^[a-zA-Z0-9]{5}$/), // 5桁の英数字のみ
    name: z.string().min(3).max(15), // 3桁以上15桁以下の文字列
  })
  .brand<"User">(); // brand<任意の文字列>()

// スキーマ定義から型生成
type User = z.infer<typeof UserSchema>;

// parseメソッドで検証
const userNG: User = UserSchema.parse({ id: "hoge!@#", name: "yu" }); // => 形式違いでエラー
const userOK: User = UserSchema.parse({ id: "abc45", name: "Taro" }); // => OK

// 直接代入
const user: User = { id: "abc45", name: "Taro" } // 形式は合っているがブランド型が無いためエラー

parseメソッドを使うことで、パース後の値には自動的に[BRAND]というシンボルが付与されるようになります。
直接代入した場合、[BRAND]が存在しないためエラーとなります。

このようにZodのbrandを使うことで、parseメソッドを通過したデータだけが型チェックをクリアし、型安全をさらに強化できます。

実際のユースケース

より実践的な例を見てみましょう。

APIレスポンス処理での活用例
api-response-example.ts
import { z } from "zod";

// APIレスポンス用のスキーマ
const ApiUserSchema = z
  .object({
    id: z.string().min(1),
    email: z.string().email(),
    name: z.string().min(1),
    role: z.enum(["admin", "user", "guest"]),
  })
  .brand<"ApiUser">();

type ApiUser = z.infer<typeof ApiUserSchema>;

// API呼び出し関数
async function fetchUser(id: string): Promise<ApiUser> {
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json();
  
  // 実行時検証 + ブランド型付与
  return ApiUserSchema.parse(data);
}

// 使用例
async function handleUser() {
  try {
    const user = await fetchUser("123");
    // userは検証済み & ブランド型付きのデータ
    console.log(user.name); // 安全にアクセス可能
  } catch (error) {
    console.error("Invalid user data:", error);
  }
}
フォームバリデーションでの活用例
form-validation-example.ts
import { z } from "zod";

// フォームデータ用のスキーマ
const ContactFormSchema = z
  .object({
    name: z.string().min(1, "名前は必須です").max(50, "名前は50文字以内で入力してください"),
    email: z.string().email("有効なメールアドレスを入力してください"),
    message: z.string().min(10, "メッセージは10文字以上で入力してください"),
  })
  .brand<"ContactForm">();

type ContactForm = z.infer<typeof ContactFormSchema>;

// フォーム送信処理
function submitContactForm(formData: unknown): ContactForm {
  // フォームデータを検証してブランド型を付与
  return ContactFormSchema.parse(formData);
}

// 使用例
function handleFormSubmit(rawData: unknown) {
  try {
    const validatedData = submitContactForm(rawData);
    // validatedDataは検証済み & ブランド型付きのデータ
    sendEmail(validatedData);
  } catch (error) {
    // バリデーションエラーの処理
    console.error("Form validation failed:", error);
  }
}

メリット

Zod + Branded Typeの組み合わせによる主なメリット:

  • コンパイル時と実行時の両方で型安全性を保証
  • 意図しないデータの混入を防止
  • APIレスポンスやフォームデータの検証が確実
  • リファクタリング時の安全性向上
  • チーム開発での品質向上

まとめ

Zodは型安全の第一歩ですが、ブランド型を組み合わせることで「意味まで含めた真の型安全」を実現できます。
型安全にこだわるなら、ぜひZod + Branded Typeの組み合わせを試してみてください!

GitHubで編集を提案

Discussion

あいや - aiya000あいや - aiya000

brandedのunpackのために、元の型のスキーマを定義して、元に戻せるようにしておくのが好きです👀

const RawUserSchema = z
  .object({
    id: z.string().regex(/^[a-zA-Z0-9]{5}$/),
    name: z.string().min(3).max(15),
  })
const UserSchema = RawUserSchema.brand<"User">()

const user: RawUser = someUser
const _  = RawUserSchema.parse(UserSchema.parse(user))
saitogosaitogo

コメントありがとうございます!
ブランド型を付与する前のスキーマを使う場面ではとても有効な手段ですね!
参考になります!

具体的にどのような場面で使うのか教えていただけると幸いです。
自分なりにリサーチしてみましたが、APIやライブラリにデータを渡すときにunpackしたスキーマを使うのかなと思いました。
ローカルで擬似的に検証コードを書いてみましたが、特にエラーは出なかったため、もし私の解釈が間違っていればご指摘お願いいたします。

検証コード
index.ts
import { z } from "zod";
import { someLibraryFunction } from "./someLibrary";

// スキーマ定義
export const RawUserSchema = z.object({
  id: z.string().regex(/^[a-zA-Z0-9]{5}$/),
  name: z.string().min(3).max(15),
});

// ブランド化したスキーマ定義
export const UserSchema = RawUserSchema.brand<"User">();

// スキーマ定義から型生成
type User = z.infer<typeof UserSchema>;

const user: User = UserSchema.parse({
  id: "abc12",
  name: "Taro",
});

// ライブラリの関数を実行
// ブランド型を付与したデータを直接渡すと型エラーになるはず...だがエラーにならない???
someLibraryFunction(user);
someLibrary.ts
import { z } from "zod";
import type { RawUserSchema } from ".";

// unpack状態のスキーマ定義から型生成
type User = z.infer<typeof RawUserSchema>;

export function someLibraryFunction(user: User) {
  console.log("ライブラリ関数が受け取ったユーザーデータ:", user);
}