DB スキーマを型とバリデーションの唯一の源泉にする設計パターンを解説します。enum/nullability/default のズレを防ぎ、drizzle-zod の自動生成も見据えた「破綻しにくい設計」に寄せます。
前提要件(公式ドキュメントより)
- drizzle-zod v0.8.0 以上
- drizzle-orm v0.36.0 以上
- zod v3.25.1 以上
なぜ「DB スキーマ駆動(Schema-first)」が必要か
フルスタック TypeScript 開発では、同じデータ構造を複数の場所で定義しがちです:
- DB スキーマ(Drizzle)
- API バリデーション(Zod)
- フロントエンド型定義
これらが乖離すると、実行時エラーや型の不整合が発生します。
この記事での SSOT の範囲
| SSOT に含める | SSOT に含めない |
|---|---|
| enum 値、nullability、default、カラム型 | UI 都合の制約(最大文字数、正規表現など) |
課題:分散した型定義
Before:db + shared パッケージ構成
my-app/
├── db/ # @my/db - Drizzle スキーマ
├── shared/ # @my/shared - 手書き Zod スキーマ
├── backend/
└── frontend/
問題点:
-
db/schema.tsとshared/schemas.tsで同じ構造を二重定義 - enum 値の追加時に 2 ファイル修正が必要
- nullable/optional の解釈ズレ
// db/src/schema.ts
export const projects = sqliteTable('projects', {
status: text('status').notNull().default('active'),
})
// shared/src/schemas.ts - 別ファイルで再定義
export const projectStatusSchema = z.enum(['active', 'archived'])
// ↑ 'archived' を追加し忘れるリスク
解決策:model パッケージへの統合
After:統合された model パッケージ
my-app/
├── model/ # @my/model - Drizzle + Zod(統合)
├── backend/
├── frontend/
└── realtime/
依存関係:
frontend → backend (型のみ), model
backend → model
model → (standalone)
realtime → model
実装パターン:enum 値の共有
Step 1: テーブル定義で enum 値を export
// model/src/schema.ts
import { sql } from 'drizzle-orm'
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { nanoid } from 'nanoid'
// enum 値を Single Source of Truth として定義
export const projectStatusValues = ['active', 'archived'] as const
export const sourceStatusValues = ['draft', 'in_progress', 'completed'] as const
export const articleStatusValues = ['draft', 'review', 'published', 'archived'] as const
export const chatRoleValues = ['user', 'assistant'] as const
export const projects = sqliteTable('projects', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
userId: text('user_id').notNull(),
name: text('name').notNull(),
goal: text('goal'),
projectPrompt: text('project_prompt'),
// enum オプションでテーブル定義に組み込む
status: text('status', { enum: projectStatusValues }).notNull().default('active'),
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
})
Step 2: Zod スキーマで enum 値を参照
// model/src/schemas.ts
import { z } from 'zod'
import {
projectStatusValues,
sourceStatusValues,
articleStatusValues,
chatRoleValues,
} from './schema'
// DB 定義から enum スキーマを生成(Single Source of Truth)
export const projectStatusSchema = z.enum(projectStatusValues)
export const sourceStatusSchema = z.enum(sourceStatusValues)
export const articleStatusSchema = z.enum(articleStatusValues)
export const chatRoleSchema = z.enum(chatRoleValues)
// Create/Update スキーマ
export const createProjectSchema = z.object({
name: z.string().min(1),
goal: z.string().optional(),
projectPrompt: z.string().optional(),
})
export const updateProjectSchema = z.object({
name: z.string().min(1).optional(),
goal: z.string().optional(),
projectPrompt: z.string().optional(),
status: projectStatusSchema.optional(),
})
// 型エクスポート
export type ProjectStatus = z.infer<typeof projectStatusSchema>
export type CreateProjectSchema = z.infer<typeof createProjectSchema>
export type UpdateProjectSchema = z.infer<typeof updateProjectSchema>
Step 3: 統一された import
// backend/src/routers/project.ts
import { createProjectSchema, projects, updateProjectSchema } from '@my/model'
export const projectRouter = router({
create: publicProcedure
.input(createProjectSchema) // Zod スキーマ
.mutation(async ({ ctx, input }) => {
return ctx.db.insert(projects).values(input) // Drizzle テーブル
}),
})
drizzle-zod で Zod スキーマを自動生成する
drizzle-zod はテーブル定義から Zod スキーマを生成でき、API リクエスト/レスポンスの契約をスキーマ駆動に寄せられます。
import { createInsertSchema, createSelectSchema } from 'drizzle-zod'
import { projects } from './schema'
// テーブルから自動生成
export const projectSchema = createSelectSchema(projects)
export const createProjectSchema = createInsertSchema(projects, {
name: (schema) => schema.min(1),
}).omit({ id: true, userId: true })
運用では「生成結果にフォーム都合の制約を追加したい」「特定 DB で挙動差がある」などが起きるため、Hybrid(生成 + 手動補強) にしておくと破綻しにくいです。
SQLite での既知の不具合
drizzle-zod で SQLite の text() が Buffer 型として推論される問題が報告されています(Issue #4705)。
この場合、本記事の enum 共有パターンを使うか、pnpm patch で修正を適用します。
詳細: pnpm の patchedDependencies で依存パッケージにパッチを当てる
テーブル設計の指針
drizzle-zod での自動生成を見据えた設計ガイドライン:
1. enum は明示的に定義
// Good: enum 値を const で定義し、テーブルに渡す
export const statusValues = ['draft', 'published'] as const
status: text('status', { enum: statusValues }).notNull().default('draft')
// Bad: 文字列リテラルを直接書く
status: text('status').notNull().default('draft')
2. nullable は意図的に表現
// nullable: text('col') - .notNull() なし
content: text('content'), // → z.string().nullable()
// non-nullable: .notNull() 明示
title: text('title').notNull(), // → z.string()
3. default は .$defaultFn() か .default() で明示
// SQLite の SQL default
createdAt: integer('created_at', { mode: 'timestamp' })
.default(sql`(unixepoch())`),
// JavaScript default(アプリ側で生成)
id: text('id').primaryKey().$defaultFn(() => nanoid()),
モノレポ移行手順(db/shared → model 統合)
1. ディレクトリリネーム
mv db model
2. package.json 更新
{
"name": "@my/model",
"dependencies": {
"drizzle-orm": "^0.38.0",
"drizzle-zod": "^0.8.0",
"zod": "^3.25.1"
}
}
3. workspace 設定更新
# pnpm-workspace.yaml
packages:
- 'frontend'
- 'backend'
- 'model' # db → model
- 'realtime'
# shared は削除
4. import パス一括置換
# エディタの検索置換で
@my/db → @my/model
@my/shared → @my/model
5. CI/CD 更新
# Dockerfile
RUN pnpm --filter @my/model build
RUN pnpm --filter @my/backend build
まとめ
| Before | After |
|---|---|
| db + shared(2 パッケージ) | model(1 パッケージ) |
| enum 二重定義 | DB 定義から取得 |
| 型の乖離リスク | Single Source of Truth |
ポイント:
- DB スキーマを型とバリデーションの源泉に
- drizzle-zod で自動生成 or enum 値の手動共有
- enum 値は
as constで定義してテーブルと Zod 両方で使用
参考リンク
- drizzle-zod 公式ドキュメント
- Drizzle ORM 公式
- pnpm patchedDependencies で依存にパッチを当てる(SQLite Buffer バグ対応含む)