Branded Type × Zod で TS4023 にハマった話
本記事では、Branded Type と Zod を組み合わせたときに TS4023 が発生した原因と、Brand という中間型を導入して解決した方法を紹介します。
みなさん Branded Type 使ってますか?
Branded Type 便利ですよね。
弊社のプロダクトでは TypeScript を用いた関数型ドメインモデリングを採用しており、Branded Type を多用しています。
よくある Branded Type の定義例はこんな感じです。
declare const _brand: unique symbol;
type Branded<T, B extends string> = T & { readonly [_brand]: B };
type UserId = Branded<string, "UserId">;
type ProductId = Branded<string, "ProductId">;
const userId: UserId = "user123" as UserId;
const productId: ProductId = userId; // ❌ 型エラー!
同じ string でも、型システム上では別物として扱われるため、誤った代入を防止できます。
API のレスポンスも Branded にしたい
弊社のプロダクトでは、ドメイン層で定義するオブジェクトに Branded Type を使っています。
// domain/user.ts
export type UserId = Branded<string, "UserId">;
export type User = {
id: UserId;
name: string;
};
クライアント側でも同じ型を使いたいのですが、API を経由すると型情報が失われてしまいます。
// サーバー側
const user: User = { id: "user123" as UserId, name: "Alice" };
// API経由でクライアントに送信...
// クライアント側
const response = await api.user.get();
const data: { id: string; name: string } = response.data;
// data.id が string 型になってしまうので、Userとして扱えない!
API の出力を毎回キャストするのも面倒ですし、型安全性が損なわれてしまいます。
弊社のプロダクトでは API のバリデーションに Zod を使っているので、
Zod の transform でキャストすることで、レスポンスをそのままドメインの型にできるのでは?と考えました。
// api/schema/user.ts
import { UserId } from "domain/types";
import { z } from "zod";
export const fetchUserOutputSchema = z.object({
userId: z.string().transform((val) => val as UserId),
name: z.string(),
});
export type FetchUserOutput = z.infer<typeof fetchUserOutputSchema>;
// → { userId: UserId, name: string }: User型 になるはず!
これならキャストの記述も API 層に集約でき、クライアント側の型安全性も保てます。
...あれ、動かない
ところが、実装後にコンパイルすると Zod を利用している API パッケージから以下のエラーが、、
error TS4023: Exported variable 'fetchUserOutputSchema' has or is using name '_brand'
from external module "/path/to/domain/types" but cannot be named.
どうやら Zod 内で Branded 型を使うと、Zod を介した型のコンパイル時に _brand シンボルの名前解決ができず、コンパイルに失敗してしまうようです。
試行錯誤してみるも
以下のような案を検討しましたが、いずれも解決には至りませんでした。
案 1. _brand シンボルのエクスポート
名前解決されていないというエラーメッセージから、最初に試しました。
Branded 型定義のある d.ts ファイル内で _brand シンボルがエクスポートされていることも確認できましたが、エラーは解消されませんでした。
案 2. Zod の brand 型を使用
Zod には独自の Branded Type を定義する .brand() メソッドがあるとのことだったため、この利用を検討しました。しかし、この機能は Zod スキーマの出力が Branded Type になる機能でした。
そのため、導入にはドメイン層の型定義自体を Zod に置き換える必要があり、ドメイン層の純粋性が損なわれるため、採用しませんでした。
// domain/types.ts に Zod を導入
import { z } from "zod";
export const UserIdSchema = z.string().brand<"UserId">();
export type UserId = z.infer<typeof UserIdSchema>;
中間型 Brand を導入して解決
いろいろと試した結果、最終的に _brand シンボルを直接 Branded 型エイリアスに埋め込むのではなく、中間型として Brand 型エイリアスを導入することで解決しました。
元のコード:
// domain/types.ts
declare const _brand: unique symbol;
export type Branded<T, B extends string> = T & { readonly [_brand]: B };
修正後:
// domain/types.ts
declare const _brand: unique symbol;
// ↓ 中間型として Brand を追加 & export
export type Brand<B extends string> = {
readonly [_brand]: B;
};
export type Branded<T, B extends string> = T & Brand<B>;
これで API パッケージからも Branded 型を問題なく参照できるようになり、TS4023 エラーも解消しました。
なぜ中間型の導入で解決したのか (推測)
詳しい原因の解明はできなかったのですが、おそらく以下のような理由ではないかと推測しています。
- TypeScript の宣言エミッタが Zod の型を出力する場合、シンボルを直接扱うのが苦手
- 中間型
Brandを導入することで、_brandシンボルが明確な型の一部として認識される - これにより、TypeScript の宣言エミッタが名前を解決しやすくなる
ただし、実際の名前解決は Zod の内部実装にも依存するため、これが正確な理由かは不明です。
まとめ
- Branded Type と Zod を組み合わせると
TS4023に遭遇することがある -
unique symbolは直接埋め込まず、中間型Brandを経由させると解決できる - 正確な原因は不明だが、中間型により名前解決がしやすくなるのが関係していそう
同様の問題に遭遇した方の参考になれば幸いです。
参考: 検証環境
- TypeScript: 5.0.0
- Zod: 3.24.1
Discussion