atama plus techblog
🏷️

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
atama plus techblog
atama plus techblog

Discussion