0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[TS]ZodでFormControlのバリデーション

Posted at

全然いける。
**「今ある 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 に寄せるのは「外から入るデータ」だけでも十分価値ある

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?