Domain層のZodスキーマを起点にした型安全設計
Zodスキーマをドメイン層に置いてクリーンアーキテクチャを実現する方法。z.inferで型を自動生成し、API・DB・フロントエンドまで一貫した型安全性を確保する設計パターン。
TypeScriptで開発していると「型定義どこに書く問題」にぶつかることがあります。
APIのレスポンス型、DBのモデル型、フロントエンドの状態型…似たような型をあちこちに書いて、片方変えたらもう片方の更新忘れてバグになる、みたいなことを何度か経験しました。
Domain層のZodスキーマを「型の唯一の起点」にすることで、この問題をほぼ解消できています。結構いい感じに機能しているので、設計を共有してみます。
アーキテクチャの全体像
まず依存関係。クリーンアーキテクチャを意識して、こんな構成にしています:
domain ← usecase ← infrastructure/routes ← apps
domain パッケージが他のどこにも依存しない、純粋な層。ここにZodスキーマでエンティティや値オブジェクトを定義しています。
Zodで型と検証を同時に定義
通常、TypeScriptでは型と検証ロジックを別々に書きます:
// 型定義
type Event = {
id: string;
title: string;
date: Date;
};
// 検証は別途...
function validateEvent(data: unknown): Event {
// 手動で検証...
}
Zodを使うと、これが一箇所で済みます:
// packages/domain/src/entities/item.ts
import { z } from "zod";
export const Item = z.object({
id: ItemId,
name: ItemName,
description: ItemDescription.optional(),
status: ItemStatus,
});
// 型も自動的に生成される
export type Item = z.infer<typeof Item>;
z.infer<typeof Item> で型が取れる。スキーマと型が常に同期しているので、「検証コードと型定義がずれる」ということが起きません。
値オブジェクトでビジネスルールを埋め込む
ItemName とかは値オブジェクトとして切り出しています:
// packages/domain/src/values/item.ts
export const ITEM_NAME_MAX_LENGTH = 50;
export const ItemName = z
.string()
.min(1)
.max(ITEM_NAME_MAX_LENGTH)
.brand("ItemName");
export type ItemName = z.infer<typeof ItemName>;
.brand() を使うと、ただの string ではなく ItemName という固有の型になります。「アイテム名に間違えてユーザー名を入れちゃった」みたいなバグがコンパイル時に弾かれる。
ファクトリ関数でエンティティを生成
エンティティの生成は専用のファクトリを通します:
// packages/domain/src/entities/factory.ts
export function entityFactory<T extends z.ZodTypeAny>(
schema: T,
): (props: z.input<T>) => z.infer<T> {
return (props) => {
const result = schema.safeParse(props);
if (!result.success) {
throw ValidationError.fromZodError(result.error);
}
return result.data;
};
}
// 使い方
export const createItem = entityFactory(Item);
Usecase層ではこう使います:
// packages/usecase/src/items/create_items.ts
const itemEntities = input.items.map((itemInput) =>
createItem({
id: generateItemId(),
name: itemInput.name,
status: itemInput.status,
}),
);
createItem() を通らないと Item 型のオブジェクトは作れない。不正なデータが紛れ込む余地がないのがいい。
Repository層での型変換
DBから取ってきたデータをエンティティに変換するときもZodを使います:
// packages/infrastructure/src/repositories/item_repository.impl.ts
async findById(id: ItemId): Promise<Item> {
const row = await this.db.select().from(items).where(eq(items.id, id));
if (!row) {
throw new NotFoundError("Item not found");
}
return validateEntity(row, Item, "Item validation failed");
}
validateEntity はDBの行をZodスキーマで検証して、通れば型付きのエンティティを返す。DBのnull/undefinedの扱いの違いもここで吸収しています。
API定義への波及
Domain層の型は、API定義にもそのまま使われます:
// packages/routes/src/item.route.ts
import { Item, ItemPatchInput } from "@myapp/domain";
export const CreateItemsResponse = z.object({
items: z.array(Item),
});
export const UpdateItemBody = ItemPatchInput;
OpenAPI仕様もこのZodスキーマから自動生成されるので、APIドキュメントも常に最新。詳細は後日書きます -> 書きました: ZodスキーマからAPIクライアントを自動生成する仕組み
フロントエンドへの伝播
生成されたAPIクライアントを通じて、フロントエンドでも同じ型が使えます:
// apps/web(React)
const { data } = useListItems({ categoryId });
// data.items は Item[] 型
Domain層で Item の定義を変えると:
- Usecase層の
createItem()が型エラー - Repository層の
validateEntity()が型エラー - API定義の
CreateItemsResponseが型エラー - 生成されたAPIクライアントが型エラー
- フロントエンドのコンポーネントが型エラー
全部一気にTypeScriptに怒られるので、修正漏れがなくなります。
null/undefinedのハンドリング
地味に悩ましいのが null と undefined の扱い。ORMにDrizzleを使っているのですが、DBでnullableなカラムは null で返ってきます。一方、TypeScriptではオプショナルなフィールドは undefined で表現するのが自然。
Domain層をインフラの都合で汚したくないので、層ごとにルールを決めています:
// Entity: undefinedのみ(.optional())
// TypeScriptの慣習に合わせてシンプルに
export const Item = z.object({
description: ItemDescription.optional(), // undefined
});
// PatchInput: null も許可(明示的にNULLに更新)
export const ItemPatchInput = z.object({
description: ItemDescription.nullable().optional(),
// undefined = 更新しない
// null = NULLに更新
});
PATCHで nullable().optional() にしているのは、「変更しない」と「NULLに変更」を区別するため。Drizzleは undefined のフィールドをSQLに含めないので、この使い分けがそのまま動作に反映されます。
Repository層では、DBから取得した null を undefined に変換してからエンティティを作ります:
// packages/infrastructure/src/repositories/helpers.ts
export function nullToUndefined<T>(value: T | null): T | undefined {
return value === null ? undefined : value;
}
// Repository実装
const entity = createItem({
...row,
description: nullToUndefined(row.description),
});
こうすることでDomain層はクリーンに保てる。最初はややこしく感じたけど、慣れると直感的です。
感じている効果
この設計にしてから:
- 「型定義どこだっけ」がなくなった(Domainを見ればいい)
- API変更時に影響範囲が明確(TypeScriptが全部教えてくれる)
- 不正なデータがシステムに入り込まない(Zodが検証してくれる)
- ドキュメントが腐らない(OpenAPIがスキーマから自動生成)
これくらいの型安全性があると安心して開発できるので、満足しています。