記事一覧に戻る
Domain層のZodスキーマを起点にした型安全設計

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 の定義を変えると:

  1. Usecase層の createItem() が型エラー
  2. Repository層の validateEntity() が型エラー
  3. API定義の CreateItemsResponse が型エラー
  4. 生成されたAPIクライアントが型エラー
  5. フロントエンドのコンポーネントが型エラー

全部一気にTypeScriptに怒られるので、修正漏れがなくなります。

null/undefinedのハンドリング

地味に悩ましいのが nullundefined の扱い。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から取得した nullundefined に変換してからエンティティを作ります:

// 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がスキーマから自動生成)

これくらいの型安全性があると安心して開発できるので、満足しています。