Skip to main content
友田 陽大
Type safety & validation
TypeScript
Zod
バリデーション
型安全
Next.js
React
フロントエンド
アーキテクチャ設計

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
Reading time
20 min read
Author
友田 陽大
Share
Contents

This article uses the latest version of the Zod official documentation (Basics, Defining schemas / API, Migration / v4, Error customization, Error formatting, JSON Schema, Metadata, Zod 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.

// よくある事故:型を「主張」しているだけで、実際には検証していない
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.

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

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.

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.

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 }
HelperWhat typeMain use
z.inputThe type before parsingForm raw values, API raw payloads
z.outputThe type after parsingThe confirmed value business logic receives
z.inferSame as z.outputGetting 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.

ItemZod 3 (old)Zod 4 (latest)
Importimport { z } from "zod"import * as z from "zod" ({ z } also OK)
String formatz.string().email()z.email() (the old form is deprecated)
ISO date/timez.string().datetime()z.iso.datetime() / z.iso.date()
Error-message specmessage / invalid_type_error / required_errorUnified to error (string or function)
Error formattingerror.format() / error.flatten()z.treeifyError / z.flattenError / z.prettifyError
JSON Schema conversionNeeds an external libraryz.toJSONSchema() built in as standard
Metadata.describe() onlyRegistry + .meta({ id, title, ... })
Bundle optimizationzod/mini (functional, tree-shaking supported)
z.literalSingle value onlyMultiple values OK z.literal([200, 201, 204])
Env var booleanSelf-implementedz.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.

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).

// 例:入力を正規化してから検証(前後空白除去 → 小文字化 → メール形式)
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.

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.

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.

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.

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.

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).

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).

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."

// 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.

// 文字列形:シンプルに固定メッセージ
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.

FunctionOutputWhen to use
z.flattenError{ formErrors, fieldErrors } (one level)Forms / API responses (most frequent)
z.treeifyErrorA nested tree reflecting the schema structure { errors, properties }Deeply nested, complex schemas
z.prettifyErrorA human-readable string with symbolsCLI, logs, debugging
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.

// 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.

"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.

// 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.

// 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).

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.

// 無印(メソッドチェーン・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
APIMethod chainFunction composition + .check()
Bundle (object example)About 13.1kbAbout 4.0kb
DX / autocomplete△ (somewhat verbose)
RecommendedBackend, most of the webSize-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

DoDon'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 endsValidate only on the client and pass the server through
Reject unexpected keys on external input with z.strictObjectAllow mass assignment by leaving it to the default strip
Format errors with flattenError / prettifyError and return themOutput 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 startupSwallow process.env.X! with as
Divide roles: transform with transform/pipe, preprocess with preprocessCram 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.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading