投稿日:2026/7/26
更新日:2026/7/26

satisfies z.ZodType<T>)普段の Zod は「スキーマが正 → z.infer で型を導出」の一方向で回る。
const schema = z.object({ ... })
type Form = z.infer<typeof schema> // 型はスキーマから生える
ところが codegen のように型が先に確定していて動かせない場合、向きが逆になる。型に合わせてスキーマを書くしかなく、ズレても誰も教えてくれない。そこで使うのが末尾の satisfies。
satisfies 演算子そのもの(型注釈 / as / as const との比較)→ 型指定についてsatisfies の一般論 → Zodとsatisfiesの併用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 に代入可能か」を検査する。
エラーの出る場所が、原因から遠くなる。
satisfies が無くてもズレはいずれ落ちる。ただし落ちるのはスキーマの定義行ではなく、値を渡す場所。
const { data } = useForm({ resolver: zodResolver(schema) })
mutation({ variables: { input: data } })
// ^^^^ ここで初めてエラーになる
これが効いてくるのは codegen が型を更新したとき。API 側でフィールドが必須化された、名前が変わった、といった変更が入ると、エラーが出るのは呼び出し側だけで、直すべきスキーマは別ファイルにある。呼び出し箇所が複数あれば同じエラーが複数出て、原因は 1 箇所、という状態になる。
satisfies を付けておけば、ズレはスキーマの定義行で 1 つだけ出る。
: 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 / .partial は ZodObject にしか無い。フォームスキーマは部分適用や再利用でこれらを使うことが多いので、実質的に型注釈は選べない。satisfies は検査だけして推論結果を保持する。
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>
理屈はシンプルで、多い分には代入できてしまうから。必須フィールドのタイポが捕まるのは「タイポした側が余分」だからではなく、「正しい名前の必須フィールドが不足している」から。任意フィールドだと不足が問題にならないので、すり抜ける。
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() を選ぶ必要がある。ここが一番ハマる。
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 引数 | Def(ZodTypeDef) |
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() などを使っていれば当然エラーになる。
z.infer で回っている限り、この書き方は要らない。型が外部から与えられて動かせないときだけの道具。
導入する価値があるのは、
この 2 つが揃っているとき。逆に、送信前に手で詰め替えている({ jobProposalId: data.id, ... } のように書いている)なら、その詰め替え箇所で型検査が効くので satisfies の追加分は小さい。
satisfies z.ZodType<T> は「型が先に決まっていて動かせない」ときの道具。普段の z.infer 方向では要らない: z.ZodType<T> では ZodObject のメソッド(.shape / .pick / .extend)が消える。だから satisfiesInputMaybe<T> には .nullish()。.optional() でも satisfies は通ってしまい、null が来たときに実行時に落ちるDef で Input のデフォルトが Output、v4 は第 2 引数が Input でデフォルトが unknown。.default() を挟んだときの挙動が変わる検証環境:zod 3.25.76(
zod/zod/v4両方)、TypeScript 7.0.2、strict: true