YASD-TECH
YASD TECH
# TypeScript

codegenの型にZodスキーマを合わせる

投稿日:2026/7/26

更新日:2026/7/26

ttitleImage

codegen の型に Zod スキーマを合わせる(satisfies z.ZodType<T>

普段の Zod は「スキーマが正 → z.infer で型を導出」の一方向で回る。

const schema = z.object({ ... })
type Form = z.infer<typeof schema>   // 型はスキーマから生える

ところが codegen のように型が先に確定していて動かせない場合、向きが逆になる。型に合わせてスキーマを書くしかなく、ズレても誰も教えてくれない。そこで使うのが末尾の satisfies


1. どういう場面か

GraphQL codegen や OpenAPI generator が吐いた input 型に、フォームの値をそのまま送るケース。

// generated/types.ts(自動生成・手で触らない)
export type InputMaybe<T> = T | null | undefined

export type ApproveJobProposalInternallyInput = {
  jobProposalId: string
  comment?: InputMaybe<string>
}

型ファイルは再生成で上書きされるので、こちらから合わせにいくことはできない。スキーマ側を型に追従させるしかない。

// 自分で書くフォームスキーマ
const schema = z.object({
  jobProposalId: z.string().min(1),
  comment: z.string().nullish(),
}) satisfies z.ZodType<ApproveJobProposalInternallyInput>

この satisfies があると、TS が「z.infer<typeof schema>ApproveJobProposalInternallyInput に代入可能か」を検査する。


2. 付けないと何が困るか

エラーの出る場所が、原因から遠くなる。

satisfies が無くてもズレはいずれ落ちる。ただし落ちるのはスキーマの定義行ではなく、値を渡す場所。

const { data } = useForm({ resolver: zodResolver(schema) })

mutation({ variables: { input: data } })
//                             ^^^^ ここで初めてエラーになる

これが効いてくるのは codegen が型を更新したとき。API 側でフィールドが必須化された、名前が変わった、といった変更が入ると、エラーが出るのは呼び出し側だけで、直すべきスキーマは別ファイルにある。呼び出し箇所が複数あれば同じエラーが複数出て、原因は 1 箇所、という状態になる。

satisfies を付けておけば、ズレはスキーマの定義行で 1 つだけ出る。


3. なぜ型注釈 : z.ZodType<T> ではないのか

型注釈は変数の型を注釈した型に広げてしまうので、ZodObject 固有のメソッドが全部消える。

// ❌ 型注釈:ZodType に潰れる
const schema: z.ZodType<Approve> = z.object({ ... })
schema.shape              // ❌ プロパティ 'shape' は型 'ZodType<...>' に存在しません
schema.pick({ ... })      // ❌
schema.extend({ ... })    // ❌
schema.partial()          // ❌

// ✅ satisfies:ZodObject のまま
const schema = z.object({ ... }) satisfies z.ZodType<Approve>
schema.shape              // ✅
schema.pick({ ... })      // ✅

.shape / .pick / .omit / .extend / .partialZodObject にしか無い。フォームスキーマは部分適用や再利用でこれらを使うことが多いので、実質的に型注釈は選べない。satisfies は検査だけして推論結果を保持する。


4. 何を検出できて、何を検出できないか

satisfies代入可能性(assignability)の検査であって、構造の一致検査ではない。ここを取り違えると「付けたから安心」と誤解する。

ズレの種類 検出
必須フィールドの不足
必須フィールドのタイポ ✅(不足として出る)
フィールドの型違いz.string()z.number()
余分なフィールド ❌ 通る
任意フィールドのタイポ ❌ 通る
型より狭いスキーマ ❌ 通る
type Approve = { jobProposalId: string; comment?: InputMaybe<string> }

// ✅ 落ちる:必須フィールドのタイポ → jobProposalId が無いと言われる
z.object({
  jobPropsalId: z.string(),      // typo
  comment: z.string().nullish(),
}) satisfies z.ZodType<Approve>
// Property 'jobProposalId' is missing in type '{ jobPropsalId: string; ... }'

// ❌ 通ってしまう:任意フィールドのタイポ
z.object({
  jobProposalId: z.string(),
  coment: z.string().nullish(),  // typo だが必須ではないので不足にならない
}) satisfies z.ZodType<Approve>

// ❌ 通ってしまう:型に無いフィールド
z.object({
  jobProposalId: z.string(),
  bogus: z.string(),
}) satisfies z.ZodType<Approve>

理屈はシンプルで、多い分には代入できてしまうから。必須フィールドのタイポが捕まるのは「タイポした側が余分」だからではなく、「正しい名前の必須フィールドが不足している」から。任意フィールドだと不足が問題にならないので、すり抜ける。


5. 一番ハマるのは InputMaybe.nullish()

codegen の InputMaybe<T>T | null | undefined。対応する Zod は .nullish() で、.optional() では足りない。

z.string().optional()   // string | undefined         ← null が入らない
z.string().nullish()    // string | null | undefined  ← InputMaybe と一致

そして .optional() にしても satisfies は通る。

const schema = z.object({
  jobProposalId: z.string(),
  comment: z.string().optional(),   // null を受け付けない
}) satisfies z.ZodType<Approve>     // ✅ エラーにならない

前節の「型より狭いスキーマは検出できない」がそのまま出ている。{ comment?: string }{ comment?: string | null } に代入できるので、型検査としては何も問題がない。

問題が出るのは実行時。

schema.safeParse({ jobProposalId: 'a', comment: null })
// → NG: Expected string, received null

つまり satisfies が守ってくれるのは「足りない・型が違う」だけで、「受け入れが狭すぎる」は守らない。API が null を送ってくる可能性のあるフィールドは、satisfies に頼らず自分で .nullish() を選ぶ必要がある。ここが一番ハマる。


6. v3 と v4 でジェネリクスの形が違う

transform / coerce / default を挟むと入力型と出力型がずれる。そのときの挙動がバージョンで変わるので注意。

// v3
declare abstract class ZodType<Output = any, Def extends ZodTypeDef = ZodTypeDef, Input = Output>

// v4
interface ZodType<out Output = unknown, out Input = unknown, out Internals ...>

第 1 引数が出力型なのは共通。違うのは第 2 引数と、Input のデフォルト

v3 v4
第 2 引数 DefZodTypeDef Input
Input のデフォルト Output と同じ unknown
z.ZodType<T> で入力側を検査するか する しない
type Config = { retryCount: number }

const schema = z.object({
  retryCount: z.number().default(3),   // 入力は省略可、出力は必須
}) satisfies z.ZodType<Config>

// v3 → ❌ エラー
//   The types of '_input.retryCount' are incompatible.
//   Type 'number | undefined' is not assignable to type 'number'.
// v4 → ✅ 通る(Input が unknown なので入力側は見られない)

入力側も明示的に縛りたい場合は、引数の位置がバージョンで違う。

satisfies z.ZodType<Config, z.ZodTypeDef, Config>   // v3
satisfies z.ZodType<Config, Config>                 // v4

GraphQL に送るのは出力(パース結果)なので、通常は第 1 引数だけ合っていればよい。入力側まで縛るのは「入出力が同じ形であること」を要求したいときで、.default() などを使っていれば当然エラーになる。


7. 入れるかどうかの判断

z.infer で回っている限り、この書き方は要らない。型が外部から与えられて動かせないときだけの道具。

導入する価値があるのは、

  • codegen の input 型にフォームの値をそのまま渡している
  • 型の再生成が定期的に走り、変更が入る

この 2 つが揃っているとき。逆に、送信前に手で詰め替えている({ jobProposalId: data.id, ... } のように書いている)なら、その詰め替え箇所で型検査が効くので satisfies の追加分は小さい。


まとめ

  1. satisfies z.ZodType<T> は「型が先に決まっていて動かせない」ときの道具。普段の z.infer 方向では要らない
  2. 効能はエラーの発生箇所がスキーマの定義行になること。無いと呼び出し側で落ちて、原因まで辿る手間がかかる
  3. 型注釈 : z.ZodType<T> では ZodObject のメソッド(.shape / .pick / .extend)が消える。だから satisfies
  4. 検出できるのは不足・型違いまで。余分なフィールド・任意フィールドのタイポ・狭すぎるスキーマは通る
  5. InputMaybe<T> には .nullish().optional() でも satisfies は通ってしまい、null が来たときに実行時に落ちる
  6. v3 は第 2 引数が DefInput のデフォルトが Output、v4 は第 2 引数が Input でデフォルトが unknown.default() を挟んだときの挙動が変わる

検証環境:zod 3.25.76(zod / zod/v4 両方)、TypeScript 7.0.2、strict: true

Index

  • codegen の型に Zod スキーマを合わせる(satisfies z.ZodType<T>)
  • 1. どういう場面か
  • 2. 付けないと何が困るか
  • 3. なぜ型注釈 : z.ZodType<T> ではないのか
  • 4. 何を検出できて、何を検出できないか
  • 5. 一番ハマるのは InputMaybe と .nullish()
  • 6. v3 と v4 でジェネリクスの形が違う
  • 7. 入れるかどうかの判断
  • まとめ