# Zod 4 Practical Guide [Latest 2026] — TypeScript Type-Safe Schema Validation, Boundary Validation, React Hook Form / Environment Variables / JSON Schema Integration

> A practical guide faithful to the latest official documentation (Zod 4 / zod@4.x). From top-level string formats (z.email etc.), the unified error parameter, treeifyError/prettifyError/flattenError, z.toJSONSchema, metadata, to Zod Mini—it explains 'when, where, and how to use' with production-quality code examples. Also covers applications to React Hook Form, environment variables, API boundaries, and LLM structured output.

- Published: 2026-06-24
- Author: 友田 陽大
- Tags: TypeScript, Zod, バリデーション, 型安全, Next.js, React, フロントエンド, アーキテクチャ設計
- URL: https://tomodahinata.com/en/blog/zod
- Category: Type safety & validation
- Pillar guide: https://tomodahinata.com/en/blog/typescript-type-safety-discipline-zod-nevererror-no-any

## Key points

- With Zod, write one schema and you get runtime validation and a static type as a single source of truth (SSoT)
- The design philosophy is Parse, don't validate—convert an untrusted unknown into a value of a trustworthy type
- In Zod 4, string formats became top-level functions (z.email etc.), and error specification was unified to error
- Zod's value is at trust boundaries (forms, APIs, environment variables, external SDK return values); double-validating already-typed internal values is an anti-pattern
- Reuse one schema across React Hook Form, environment-variable fail-fast, API boundaries, and LLM structured output via z.toJSONSchema

---

This article uses the latest version of the Zod official documentation ([Basics](https://zod.dev/basics), [Defining schemas / API](https://zod.dev/api), [Migration / v4](https://zod.dev/v4), [Error customization](https://zod.dev/error-customization), [Error formatting](https://zod.dev/error-formatting), [JSON Schema](https://zod.dev/json-schema), [Metadata](https://zod.dev/metadata), [Zod Mini](https://zod.dev/packages/mini)) as primary sources, digging in to the point where you can judge "in which scene and how to write" in practice.

---

## 1. Why Zod (the "single source of truth" for types and validation)

TypeScript types **disappear at compile time.** Pass `tsc` and type errors vanish, but JSON, form input, and environment variables that arrive at runtime are data from **outside the type system**, and are only `any` or `unknown`. Many bugs are born here.

```ts
// よくある事故：型を「主張」しているだけで、実際には検証していない
const res = await fetch("/api/user");
const user = (await res.json()) as User; // ← as は嘘をつける。実行時は何の保証もない
user.email.toLowerCase(); // email が undefined なら本番でクラッシュ
```

`as` only "silences the compiler" and **does not perform a single byte of validation.** Zod inserts **runtime inspection** at this boundary, and simultaneously **derives a static type** from that inspection.

```ts
import * as z from "zod";

const User = z.object({
  id: z.uuid(),
  email: z.email(),
  name: z.string().min(1),
});

type User = z.infer<typeof User>; // ← スキーマから型を生成（手書きの type 定義は不要）

const res = await fetch("/api/user");
const user = User.parse(await res.json()); // 実行時に検証し、user は User 型として安全
```

The point is the design philosophy **"Parse, don't validate."** Zod works not as a function that "returns a `boolean` for whether it's correct" but as one that "**converts an untrusted `unknown` into a value of a trustworthy type.**" The code past the validation can be written safely assuming the type. This is the biggest reason to bring in Zod.

> **The principle of application scope: place Zod only at trust boundaries.** Once you `parse` "an `unknown` coming from outside"—an API, a form, an environment variable, an external SDK's return value—leave everything inside the app after that to TypeScript's types. Re-validating an already-typed value at every function is a waste of cost and an SRP violation.

---

## 2. Basics: parse / safeParse and type inference

### 2-1. Schema definition and parsing

```ts
import * as z from "zod";

const Player = z.object({
  username: z.string(),
  xp: z.number(),
});

// .parse(): 検証に成功すると「入力のディープクローン」を型付きで返す。失敗時は throw
Player.parse({ username: "billie", xp: 100 }); // => { username: "billie", xp: 100 }

// 非同期の検証（refine が Promise を返す等）を含む場合は parseAsync
await Player.parseAsync({ username: "billie", xp: 100 });
```

### 2-2. `safeParse` that doesn't throw (the practical default)

In API handlers and forms, **`.safeParse()` that returns a result object** is easier to handle than `.parse()` that throws, and the control flow becomes straightforward. The return value is a discriminated union.

```ts
const result = Player.safeParse({ username: 42, xp: "100" });

if (!result.success) {
  result.error; // ZodError インスタンス（result.error.issues に詳細）
} else {
  result.data; // { username: string; xp: number } として型がつく
}

// 非同期版
await Player.safeParseAsync(input);
```

When you `try/catch` with `.parse()`, catch with `error instanceof z.ZodError` and reference `error.issues` (the array of validation problems).

### 2-3. Type inference: `z.infer` / `z.input` / `z.output`

Extracting types from a schema is Zod's true forte. **When there's a transform (`transform`) or a default (`default`), the input type and output type diverge**, so use the three appropriately.

```ts
const FormSchema = z.object({
  // 入力は文字列、出力は数値（変換あり）
  age: z.string().transform((s) => Number.parseInt(s, 10)),
});

type In = z.input<typeof FormSchema>; // { age: string }   ← パース前（フォームの生値）
type Out = z.output<typeof FormSchema>; // { age: number }  ← パース後（業務ロジックが受け取る値）

// z.infer は z.output のエイリアス（= 検証後の最終型）
type Final = z.infer<typeof FormSchema>; // { age: number }
```

| Helper | What type | Main use |
| ----------- | --------------------------- | ------------------------------------ |
| `z.input`   | The type **before** parsing | Form raw values, API raw payloads |
| `z.output`  | The type **after** parsing  | The confirmed value business logic receives |
| `z.infer`   | Same as `z.output`          | Getting the type of a normal schema with no transform |

---

## 3. [Zod 4's latest spec] The diff map from v3

Many existing articles are still Zod 3. Grasp this and you become up-to-date at once.

| Item | Zod 3 (old) | Zod 4 (latest) |
| ---------------------- | --------------------------------------- | ---------------------------------------------- |
| Import | `import { z } from "zod"` | `import * as z from "zod"` (`{ z }` also OK) |
| String format | `z.string().email()` | **`z.email()`** (the old form is deprecated) |
| ISO date/time | `z.string().datetime()` | **`z.iso.datetime()` / `z.iso.date()`** |
| Error-message spec | `message` / `invalid_type_error` / `required_error` | **Unified to `error` (string or function)** |
| Error formatting | `error.format()` / `error.flatten()` | **`z.treeifyError` / `z.flattenError` / `z.prettifyError`** |
| JSON Schema conversion | Needs an external library | **`z.toJSONSchema()` built in as standard** |
| Metadata | `.describe()` only | **Registry + `.meta({ id, title, ... })`** |
| Bundle optimization | — | **`zod/mini` (functional, tree-shaking supported)** |
| `z.literal` | Single value only | **Multiple values OK** `z.literal([200, 201, 204])` |
| Env var boolean | Self-implemented | **`z.stringbool()`** ("true"/"1"/"yes" etc.) |

> **Migration can be staged.** The old APIs (`z.string().email()`, `message`) are deprecated but **still work**, so the build won't break. Lean new code toward the new form and gradually fix the files you touch (the Boy Scout Rule)—that's safe.

---

## 4. Primitives, string formats, and type coercion (coerce)

### 4-1. Top-level string formats

In Zod 4, formats heavily used in practice became **top-level functions.** Shorter than the `z.string().email()` method form, and advantageous for tree-shaking too.

```ts
z.email(); // メール
z.uuid(); // UUID（z.uuidv4() / z.uuidv7() も）
z.url(); // URL（z.httpUrl() は http/https 限定）
z.e164(); // 電話番号（E.164）
z.jwt(); // JWT（z.jwt({ alg: "HS256" }) でアルゴリズム指定）
z.base64(); // Base64
z.ipv4(); // IPv4（z.cidrv4() で CIDR ブロック）
z.nanoid(); // nanoid / z.cuid2() / z.ulid()

// ISO 系は z.iso 名前空間に集約
z.iso.date(); // "YYYY-MM-DD"
z.iso.datetime(); // ISO 8601 日時
z.iso.time(); // "HH:MM:SS"

// メール正規表現は用途に応じて差し替え可能（厳密さ vs 寛容さのトレードオフ）
z.email({ pattern: z.regexes.html5Email }); // ブラウザ標準準拠
z.email({ pattern: z.regexes.rfc5322Email }); // RFC 5322 準拠
```

Basic string validation and transformation are alive too. Besides `min` / `max` / `regex` / `startsWith`, being able to **build normalizations like `trim()` / `toLowerCase()` into the validation pipeline** is quietly powerful (you can consolidate manual preprocessing in one place).

```ts
// 例：入力を正規化してから検証（前後空白除去 → 小文字化 → メール形式）
const Email = z.string().trim().toLowerCase().pipe(z.email());
Email.parse("  Foo@Example.com "); // => "foo@example.com"
```

### 4-2. Type coercion (coerce) and number formats

**"It comes as a string but I want to treat it as a number"**—for the typical examples of query parameters, environment variables, and forms, `z.coerce` works. It passes through `Number(input)` etc. internally before validating.

```ts
z.coerce.number(); // Number(input) してから number 検証
z.coerce.boolean(); // Boolean(input)（注意：空文字以外は基本 true）
z.coerce.date(); // new Date(input)

// 数値の精密フォーマット（v4 で拡充）
z.int(); // 安全整数のみ
z.int32(); // 32bit 整数の範囲
z.number().positive().multipleOf(0.01); // 正の数・小数第2位まで（金額など）
```

> **The pitfall of `z.coerce.boolean()`:** `"false"` is **a non-empty string, so it becomes `true`.** To correctly handle `"true"/"false"` of environment variables, use `z.stringbool()` from the next chapter.

---

## 5. Object design: strict / loose / composition / recursion / discriminated unions

### 5-1. Handling unknown keys (directly tied to security)

`z.object` **silently strips unknown keys by default.** Use the three modes appropriately per use.

```ts
z.object({ name: z.string() }); // strip（既定）：未知キーは出力から落とす
z.strictObject({ name: z.string() }); // strict：未知キーがあればエラー
z.looseObject({ name: z.string() }); // loose：未知キーをそのまま通す
```

For external input, **`strictObject` to "reject unexpected fields"** is defensive (a countermeasure against mass assignment). On the other hand, for response validation of a public API where you want to leave backward compatibility, the default strip is the safe bet.

### 5-2. Schema composition (the key to DRY)

By deriving from one base schema, you eliminate duplication of type definitions.

```ts
const User = z.object({
  id: z.uuid(),
  email: z.email(),
  name: z.string(),
  passwordHash: z.string(),
});

// 作成入力：id は不要、password は生文字列
const CreateUser = User.omit({ id: true, passwordHash: true }).extend({
  password: z.string().min(12),
});

// 更新入力：全フィールド任意
const UpdateUser = CreateUser.partial();

// 公開用：機密フィールドを除外
const PublicUser = User.omit({ passwordHash: true });

// 他にも：.pick() / .required() / .keyof() / .extend() / .catchall()
```

These are **algebraic operations on schemas.** Fix `User` once and the type and validation propagate to all derivatives, satisfying ETC (Easy To Change).

### 5-3. Recursive schemas (no cast needed in v4)

In Zod 4, using **getters**, you can express recursion and mutual recursion without type casts.

```ts
const Category = z.object({
  name: z.string(),
  get subcategories() {
    return z.array(Category); // 自己参照
  },
});

// 相互再帰も自然に書ける
const User = z.object({
  email: z.email(),
  get posts() {
    return z.array(Post);
  },
});
const Post = z.object({
  title: z.string(),
  get author() {
    return User;
  },
});
```

### 5-4. Discriminated unions (the definitive choice for API result types)

A **tagged union** like "on success `data`, on failure `error`" is best handled by `z.discriminatedUnion`. Because it branches on the discriminant key, the error messages are more accurate and faster than a normal `z.union`.

```ts
const ApiResult = z.discriminatedUnion("status", [
  z.object({ status: z.literal("success"), data: z.string() }),
  z.object({ status: z.literal("error"), code: z.number(), message: z.string() }),
]);

const parsed = ApiResult.parse(payload);
if (parsed.status === "success") {
  parsed.data; // ← 型が success 側に絞り込まれる
} else {
  parsed.code; // ← error 側に絞り込まれる
}
```

In Zod 4, you can now **nest a union, a pipe, or another discriminated union** inside a discriminated union, greatly raising the expressiveness.

---

## 6. Custom validation and transformation: refine / superRefine / transform / pipe

### 6-1. `refine`: a single logical check

Business rules that built-in validators can't express are added with `refine`. Specifying `path` lets you **tie the error to a specific field** (important for form display).

```ts
const SignUp = z
  .object({
    password: z.string().min(12),
    confirm: z.string(),
  })
  .refine((data) => data.password === data.confirm, {
    error: "パスワードが一致しません",
    path: ["confirm"], // confirm フィールドのエラーとして扱う
  });
```

### 6-2. `superRefine` / `check`: add multiple problems at once

When you want to emit **multiple errors** in one validation, or vary them by context, use `superRefine` (a low-level API; in Zod 4 `.check` is the same line).

```ts
const Password = z.string().superRefine((val, ctx) => {
  if (val.length < 12) {
    ctx.addIssue({ code: "custom", message: "12文字以上にしてください" });
  }
  if (!/[A-Z]/.test(val)) {
    ctx.addIssue({ code: "custom", message: "大文字を1文字以上含めてください" });
  }
  if (!/[0-9]/.test(val)) {
    ctx.addIssue({ code: "custom", message: "数字を1文字以上含めてください" });
  }
});
```

### 6-3. `transform` / `pipe` / `preprocess`: reshaping the value

You can **reshape the value into another form** simultaneously with validation. `transform` changes the output type, and `pipe` is composition that "connects one schema's output to the next schema's input."

```ts
// transform：検証後に変換（入力 string → 出力 number）
const StrToLen = z.string().transform((s) => s.length);

// pipe：正規化 → 再検証の連結
const Slug = z
  .string()
  .trim()
  .toLowerCase()
  .pipe(z.string().regex(/^[a-z0-9-]+$/, { error: "英小文字・数字・ハイフンのみ" }));

// preprocess：検証「前」に前処理（型が不定な生入力の整形）
const Quantity = z.preprocess((v) => (v === "" ? undefined : Number(v)), z.number().int().positive());
```

> **When to use which:** for a check that doesn't change the value, `refine`; to normalize while validating, `transform`/`pipe`; to clean up rough input before validation, `preprocess`. When you want to tidy only the value without changing the type (and keep JSON Schema generation), the v4-new `.overwrite()` works.

---

## 7. Error design: messages, formatting, localization (Japanese)

This is directly tied to **user experience (UX) and accessibility (a11y).** Validation's job is not just to "reject" but to **convey "what to fix and how."**

### 7-1. Unification to the `error` parameter

In Zod 4, the error specs once split into three (`message` / `invalid_type_error` / `required_error`) were unified to **a single `error`.** It can be passed as a string or a function.

```ts
// 文字列形：シンプルに固定メッセージ
z.string().min(5, { error: "5文字以上で入力してください" });
z.string({ error: "文字列を入力してください" });

// 関数形：状況（未入力 or 型不一致）で出し分け
z.string({
  error: (iss) => (iss.input === undefined ? "必須項目です" : "文字列で入力してください"),
});

// 検証コンテキストの活用（最小値などを動的に埋め込む）
z.string().min(8, {
  error: (iss) => `パスワードは${iss.minimum}文字以上必要です`,
});
```

### 7-2. Error formatting: three by use

**Don't output a `ZodError` to the screen as-is.** Format it per use.

| Function | Output | When to use |
| ----------------- | ------------------------------------------------------- | ------------------------------------ |
| `z.flattenError`  | `{ formErrors, fieldErrors }` (one level)               | **Forms / API responses (most frequent)** |
| `z.treeifyError`  | A nested tree reflecting the schema structure `{ errors, properties }` | Deeply nested, complex schemas |
| `z.prettifyError` | A human-readable string with symbols                    | CLI, logs, debugging |

```ts
const result = SignUp.safeParse(input);
if (!result.success) {
  // フォーム用：fieldErrors.confirm?.[0] のように引ける
  const flat = z.flattenError(result.error);
  // => { formErrors: [...], fieldErrors: { confirm: ["パスワードが一致しません"] } }

  // ログ用：そのまま console に出せる読みやすい文字列
  console.error(z.prettifyError(result.error));
}
```

### 7-3. Japanese locale and global settings

You can **localize the default English messages to Japanese for the whole app.** The built-in locales include `ja`.

```ts
// app の最上位（例：layout やエントリ）で一度だけ設定
import * as z from "zod";
import { ja } from "zod/locales";

z.config(ja()); // 既定メッセージが日本語になる

// さらに横断ルールを上書きしたい場合
z.config({
  customError: (iss) => {
    if (iss.code === "invalid_type") return `型が不正です（期待: ${iss.expected}）`;
  },
});
```

The **priority order** of errors is "① the message in the schema definition → ② the message passed at parse time → ③ `z.config`'s global → ④ the locale default." Remember it as individual > global takes effect.

---

## 8. Application ①: React Hook Form integration (`zodResolver`)

Forms are exactly Zod's main battlefield. Just by inserting `@hookform/resolvers`'s (v5 supports Zod 4) `zodResolver`, **types, validation, and error messages become end-to-end consistent.**

```tsx
"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";

const ContactSchema = z.object({
  name: z.string().min(2, { error: "お名前は2文字以上で入力してください" }),
  email: z.email({ error: "正しいメールアドレスを入力してください" }),
  message: z.string().min(20, { error: "20文字以上で具体的に入力してください" }),
});

type ContactInput = z.infer<typeof ContactSchema>;

export function ContactForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<ContactInput>({
    resolver: zodResolver(ContactSchema),
    mode: "onBlur", // フォーカスを外したタイミングで検証（過剰な再検証を避ける）
  });

  return (
    <form onSubmit={handleSubmit(async (data) => { /* data は ContactInput 型で安全 */ })} noValidate>
      <label htmlFor="name">お名前</label>
      <input
        id="name"
        {...register("name")}
        aria-invalid={!!errors.name}
        aria-describedby={errors.name ? "name-error" : undefined}
      />
      {/* a11y：エラーは aria-describedby で関連付け、role/aria-live で読み上げ */}
      {errors.name && (
        <p id="name-error" role="alert" aria-live="polite">
          {errors.name.message}
        </p>
      )}
      {/* email / message も同様 */}
      <button type="submit" disabled={isSubmitting}>送信</button>
    </form>
  );
}
```

Key points:

- Attach **`noValidate`** to the form to turn off the browser's standard validation UI and unify on Zod (message consistency).
- **The a11y essentials are a set of 3:** `aria-invalid` on the error input, give the error text an `id` and tie it with `aria-describedby`, and notify screen readers with `role="alert"` + `aria-live="polite"`.
- **Use the same `ContactSchema` with `safeParse` on the server too**, and you defend doubly without trusting the client (next chapter). One schema, applied at both ends—this is the coexistence of DRY and safety.

---

## 9. Application ②: environment-variable fail-fast at startup

The accident of "production falls because `process.env.X` is `undefined`" can be eradicated by **validating once at startup and rejecting.** `z.coerce` and the v4-new `z.stringbool()` work.

```ts
// src/env.ts —— アプリ起動時に一度だけ評価される
import * as z from "zod";

const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]),
  DATABASE_URL: z.url(), // 形式まで検証
  PORT: z.coerce.number().int().positive().default(3000), // "3000"（文字列）→ 3000
  ENABLE_CACHE: z.stringbool().default(false), // "true"/"1"/"yes" → true、"false"/"0" → false
  SENTRY_DSN: z.url().optional(),
});

// 失敗したら「何の変数が・なぜ」ダメかを表示して即終了（fail-fast）
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
  console.error("❌ 環境変数が不正です:\n", z.prettifyError(parsed.error));
  throw new Error("Invalid environment variables");
}

export const env = parsed.data; // 以降は env.PORT が number として型安全に使える
```

This makes "missing secrets, mistyped URLs, misinterpreted booleans" found **deterministically** right after a deploy. Don't output the `.env` values themselves to logs (keep messages to variable names and reasons).

---

## 10. Application ③: API boundary (Next.js Route Handler / Server Action)

The principle is that the server **trusts the client not at all.** In a Route Handler, always `safeParse` the received `unknown`, and return 400 if invalid.

```ts
// app/api/contact/route.ts
import { NextResponse } from "next/server";
import * as z from "zod";

const ContactSchema = z.strictObject({
  // strictObject で想定外フィールドを拒否（防御的）
  name: z.string().min(2).max(50),
  email: z.email(),
  message: z.string().min(20).max(2000),
});

export async function POST(req: Request) {
  const json = await req.json().catch(() => null); // 壊れた JSON も握る
  const parsed = ContactSchema.safeParse(json);

  if (!parsed.success) {
    // クライアントには整形済みのフィールドエラーだけ返す（内部情報を漏らさない）
    return NextResponse.json(
      { error: z.flattenError(parsed.error).fieldErrors },
      { status: 400 },
    );
  }

  // parsed.data はここで完全に型安全。以降の処理は型を信頼してよい
  await sendEmail(parsed.data);
  return NextResponse.json({ ok: true });
}
```

The same pattern works in a Server Action too. Being thorough about **"parse once at the boundary, leave the inside to types"** consolidates the whole codebase's defense line in `route.ts` / actions, making it easy to review (SRP).

---

## 11. Application ④: to OpenAPI and LLM structured output with `z.toJSONSchema()`

Zod 4 can **generate JSON Schema as standard.** This lets you reuse one Zod schema for **3 uses**: "runtime validation," "TypeScript type," and "JSON Schema." It's extremely useful for OpenAPI documentation generation and as **a schema for LLM structured output (structured output / function calling).**

```ts
import * as z from "zod";

const Article = z.object({
  title: z.string().meta({ description: "記事のタイトル", examples: ["Zod 4 入門"] }),
  tags: z.array(z.string()).meta({ description: "関連タグ" }),
  publishedAt: z.iso.datetime(),
});

// メタデータ（.meta）も JSON Schema に反映される
const jsonSchema = z.toJSONSchema(Article, { target: "draft-2020-12" });

// 入力 vs 出力の型差も制御できる（transform を含むスキーマで有用）
z.toJSONSchema(Article, { io: "input" }); // パース前の形
```

Main options: `target` (`draft-2020-12` [default] / `draft-07` / `openapi-3.0`), `io` (`"input"` / `"output"`), `unrepresentable` (handling unrepresentable types like `bigint`), `reused: "ref"` (extract duplicate schemas into `$defs`).

> **The key point for generative-AI integration:** pass the LLM "output according to this JSON Schema," and **`parse` the returned JSON with the same Zod schema**, and you can guarantee the model output's validity at runtime. The schema definition, the prompt constraint, and the output validation are **consolidated in a single `Article`**, with no way to diverge. In the generative-AI products I work on too, I make this "schema-driven" approach the foundation of reliability.

---

## 12. Performance and Zod Mini (the bundle-optimization decision)

Zod 4's core implementation was renewed, **about 14× faster for strings, about 7× for arrays, and about 6.5× for objects** versus v3. For most apps, **the plain `zod` as-is** is sufficient.

Only when **the frontend bundle size is truly critical** (slow lines are your main customers, etc.) should you consider the functional, tree-shaking-premised **`zod/mini`.**

```ts
// 無印（メソッドチェーン・DX 重視）
import * as z from "zod";
const a = z.string().min(5).max(10).optional();

// Mini（関数合成・サイズ重視）。.check() に制約を渡す
import * as zm from "zod/mini";
const b = zm.optional(zm.string().check(zm.minLength(5), zm.maxLength(10)));
```

| | Zod (plain) | Zod Mini |
| -------------- | ------------------------- | --------------------------------- |
| API | Method chain | Function composition + `.check()` |
| Bundle (object example) | About 13.1kb | About 4.0kb |
| DX / autocomplete | ◎ | △ (somewhat verbose) |
| Recommended | Backend, most of the web | Size-strict frontends |

> **The decision criterion:** on the backend the bundle-size impact is negligible, so plain without hesitation. On the frontend too, **measure with plain first**, and switch to Mini only **after you prove with bundle analysis** that Zod is dominant—this fits YAGNI. Mini doesn't bundle the English locale, so error wording needs explicit setting with `z.config(...)`.

---

## 13. Best practices & anti-patterns

| Do | Don't |
| --------------------------------------------------------------- | ------------------------------------------------------------ |
| `parse` once at the trust boundary (API/form/env/external SDK) | Re-validate already-typed internal values at every function |
| Derive the type with `z.infer` from the schema (single source of truth) | Hand-write a `type` and double-manage it with the Zod schema |
| Use the same schema at both the client and server ends | Validate only on the client and pass the server through |
| Reject unexpected keys on external input with `z.strictObject` | Allow mass assignment by leaving it to the default strip |
| Format errors with `flattenError` / `prettifyError` and return them | Output a `ZodError` raw to the screen/response |
| New form for strings `z.email()` / `z.iso.datetime()` | Newly write the deprecated `z.string().email()` |
| Fail-fast env with `safeParse` at startup | Swallow `process.env.X!` with `as` |
| Divide roles: transform with `transform`/`pipe`, preprocess with `preprocess` | Cram both validation and transformation into one `refine` |
| Convert `unknown` to a type with `parse`, not `as` | "Feel validated" with `as User` |

---

## 14. FAQ (frequently asked questions)

**Q. What's the difference between Zod and TypeScript's `type` / `interface`?**
A. `type` / `interface` are static promises that **disappear at compile time** and validate not a single byte at runtime. Zod **validates at runtime** and **also derives a static type** from that validation. The correct division is Zod for data coming from outside, TypeScript types for internal logic.

**Q. Can I use `z.string().email()` in Zod 4?**
A. It works, but it's **deprecated.** Use the new form `z.email()` (similarly `z.uuid()` / `z.url()` / `z.iso.datetime()`). It's shorter and advantageous for tree-shaking.

**Q. Should I use `parse` or `safeParse`?**
A. For a quick script where you want to escape globally with an exception, `parse`; for other practice (APIs, forms), **`safeParse` that returns a result object is the default.** The control flow becomes straightforward and errors are easier to catch deliberately.

**Q. I want the error messages in Japanese.**
A. `import { ja } from "zod/locales"` and call `z.config(ja())` **once at app startup**, and the default messages become Japanese. Individual messages can be overridden with each schema's `error` parameter.

**Q. Won't validation be doubled on the client and server?**
A. That's correct. Make the schema definition **one**, and **apply it at both ends**—the client (for immediate display for UX) and the server (because you don't trust the client). The definition is single, so it doesn't diverge (DRY).

**Q. I'm worried about the bundle size. Should I use Zod Mini?**
A. On the backend it's unneeded, and on the frontend too **measure with plain first** before deciding. Zod 4's plain version is already fast and light. Switch to `zod/mini` only when you can **prove with bundle analysis** that Zod is dominant (YAGNI).

**Q. Can I use Zod schemas for OpenAPI or LLM structured output?**
A. You can generate JSON Schema with `z.toJSONSchema()`. It can be used for OpenAPI integration, presenting the schema for an LLM's structured output and **re-validating the output** (`parse` with the same schema), and the definition is consolidated.

---

## Summary: a schema is "the app's trust boundary"

The key to mastering Zod 4 is to design the schema not as "a tool to make types" but as "**a boundary line that converts untrusted data into a trustworthy type.**" Looking back at this article's pillars—

1. **"Parse, don't validate"**—stop using `as`, and `parse` at the boundary to convert `unknown` into a type.
2. **Single source of truth**—derive the type with `z.infer` from one schema, eradicating double management.
3. **Zod 4's new form**—handle errors correctly with `z.email()`, the unified `error`, and `flattenError`/`prettifyError`.
4. **Defend at both ends**—apply the same schema to both React Hook Form and the API boundary.
5. **Reuse**—use one schema for many uses, from env fail-fast to OpenAPI and LLM integration via `z.toJSONSchema()`.

These are not surface-level techniques but **a "design choice"** that simultaneously raises type safety, security, maintainability, and resilience. Applied correctly, both the user experience (accurate error display) and the developer experience (hard-to-break boundaries) are raised.

In a real product, it reaches production quality only once you include here "**covering the boundaries**," "**observing errors (logging/monitoring)**," and "**versioning the schema.**" **If you need boundary design and review centered on type safety, or hardening of existing code, feel free to consult me.** The case study below introduces the process of designing and implementing a B2B SaaS supporting an industry's core operations, with an emphasis on type safety, resilience, and maintainability.
