全然いける。
**「今ある TS のモデルはそのまま使う / 真実のソースにする」+「必要なところだけ Zod を後付け」**って構成にすればOK。
⸻
1️⃣ 型を先に定義して、Zod を「寄せる」パターン
今こんな感じで型だけあるとする:
// models.ts
export type LoginFormModel = {
userName: string;
password: string;
};
ここに あとから Zod を足すときはこう:
// zod.schema.ts
import { z } from "zod";
import type { LoginFormModel } from "./models";
// どっちか好きな方でOK
// パターンA: 型注釈パターン
export const LoginSchema: z.ZodType = z.object({
userName: z.string().min(1, "ユーザー名は必須"),
password: z.string().min(8, "パスワードは8文字以上"),
});
// パターンB: satisfies パターン
export const LoginSchema2 = z.object({
userName: z.string().min(1, "ユーザー名は必須"),
password: z.string().min(8, "パスワードは8文字以上"),
}) satisfies z.ZodType;
これで:
• TS の LoginFormModel が真実のソース
• Zod のスキーマは「この型と矛盾したらコンパイルエラー」
になるから、全部を Zod 起点に書き直す必要はない。
👍 z.infer は「Zod 起点」にしたい時だけ使う、
今回みたいに「既存モデルあり」の場合は
ZodType<既存型> or satisfies ZodType<既存型> で寄せる。
⸻
2️⃣ ユニオン型との併用
例えば、今こういうモデルがすでにあるとする:
// models.ts
export type Circle = {
kind: "circle";
radius: number;
};
export type Square = {
kind: "square";
size: number;
};
export type Shape = Circle | Square;
これに対して Zod を後付けするなら:
// zod.schema.ts
import { z } from "zod";
import type { Circle, Square, Shape } from "./models";
const CircleSchema: z.ZodType = z.object({
kind: z.literal("circle"),
radius: z.number().min(0),
});
const SquareSchema: z.ZodType = z.object({
kind: z.literal("square"),
size: z.number().min(0),
});
export const ShapeSchema: z.ZodType = z.discriminatedUnion("kind", [
CircleSchema,
SquareSchema,
]);
• TS 側では普通に Shape をユニオン型として使える
• Zod 側では ShapeSchema.safeParse(x) で runtime バリデーション
• 型がズレたら Zod 側の定義でコンパイルエラーが出るので安心
⸻
3️⃣ 「ユニオンされた型の分割」も TS + Zod で対応できる
TS のみ(型ガード)で分割
function isCircle(shape: Shape): shape is Circle {
return shape.kind === "circle";
}
function handleShape(shape: Shape) {
if (isCircle(shape)) {
// Circle として扱える
console.log(shape.radius);
} else {
// Square
console.log(shape.size);
}
}
Zod でパース + TS で分割
const result = ShapeSchema.safeParse(input);
if (!result.success) {
// バリデーションエラー
console.log(result.error);
return;
}
const shape: Shape = result.data;
if (shape.kind === "circle") {
// Circle
} else {
// Square
}
「Zod で runtime まで保証 → その後は TS のユニオン分割」って感じで組める。
⸻
4️⃣ どのモデルまで Zod 化するか問題
全部を zod.schema.ts 起点にすると不安って感覚はめっちゃわかるので、
現実的にはこんな方針がいいかなと思う:
✅ Zod を書くべきところ(優先度高)
• API 入出力(API クライアントのレスポンスDTO)
• フォームの入力(外界から入ってくる値)
• DB レコード or 設定ファイル の読み込み結果
→ 「外から入ってくるデータ」だけ Zod で守る、くらいのノリで十分強い。
⛔ 無理に Zod にしなくていいところ
• アプリ内部だけで完結している中間モデル
• 単純な計算結果の型
• UI 状態用の軽い型
→ ここは TS の type/interface のままでOK。
必要になったら Zod を後から生やせばいい。
⸻
5️⃣ ざっくりおすすめ構成
こんなフォルダ構成をイメージしてみて:
src/
app/
features/
login/
login.models.ts // 既存の型定義(true source)
login.schemas.ts // Zod スキーマ(models に寄せる)
login.component.ts // FormBuilder, コンポーネント本体
login.models.ts:
export type LoginFormModel = {
userName: string;
password: string;
};
login.schemas.ts:
import { z } from "zod";
import type { LoginFormModel } from "./login.models";
export const LoginSchema: z.ZodType = z.object({
userName: z.string().min(1, "ユーザー名は必須"),
password: z.string().min(8, "パスワードは8文字以上"),
});
login.component.ts では:
• 型は LoginFormModel
• 実データのチェックに LoginSchema.safeParse()
• FormBuilder の FormGroup の shape は LoginFormModel に合わせる
ってやると、今の資産もそのまま活かせる。
⸻
まとめ
• 🔹 既にある TS のモデルを捨てる必要はない
• 🔹 それを ZodType<既存型> or satisfies ZodType<既存型> で「後付け検証レイヤー」にするのがおすすめ
• 🔹 ユニオンも普通に TS で定義して、Zod からそれに寄せればOK
• 🔹 Zod に寄せるのは「外から入るデータ」だけでも十分価値ある