Skip to content

ZodExactOptional properties are incorrectly marked as required in generated OpenAPI schema #363

Description

@FunamaYukina

When using Zod v4's z.string().exactOptional() inside a z.object(), the generated OpenAPI schema incorrectly includes the property in the required array. This is because isOptionalSchema() in src/lib/zod-is-type.ts uses schema.safeParse(undefined).success to determine if a property is optional, but ZodExactOptional rejects undefined as a value while still allowing the key to be absent from the object.

Expected behavior

Properties defined with .exactOptional() should not be included in the required array of the OpenAPI schema, because exactOptional allows the property key to be absent from the object.

In JSON/OpenAPI terms, required means "this key must be present in the JSON object". Since exactOptional allows key absence, it should be treated as not required — the same as .optional().

Current behavior

Properties defined with .exactOptional() are included in the required array.

Reproduction

import { z } from "zod/v4";
import {
  OpenAPIRegistry,
  OpenApiGeneratorV31,
  extendZodWithOpenApi,
} from "@asteasolutions/zod-to-openapi";

extendZodWithOpenApi(z);

const registry = new OpenAPIRegistry();

const schema = z.object({
  name: z.string(),
  nickname: z.string().optional(),
  bio: z.string().exactOptional(),
});

registry.register("User", schema);

const generator = new OpenApiGeneratorV31(registry.definitions);
const doc = generator.generateDocument({
  openapi: "3.1.0",
  info: { title: "Test", version: "1.0.0" },
});

const userSchema = doc.components?.schemas?.User as any;
console.log("required:", userSchema.required);
// Actual:   required: ["name", "bio"]
// Expected: required: ["name"]

Root cause

In src/lib/zod-is-type.ts:

export function isOptionalSchema(schema: z.ZodType) {
  return schema.safeParse(undefined).success;
}

This function is called from src/transformers/object.ts to determine which properties to include in the required array:

private requiredKeysOf(objectSchema: ZodObject) {
  return Object.entries(objectSchema.def.shape)
    .filter(([_key, type]) => !isOptionalSchema(type))
    .map(([key, _type]) => key);
}

The issue:

  • z.string().optional().safeParse(undefined)success: true → correctly excluded from required
  • z.string().exactOptional().safeParse(undefined)success: falseincorrectly included in required

ZodExactOptional semantics in Zod v4:

  • Key absence is allowed (the property is optional in the object)
  • Value undefined is not allowed (if the key is present, it must be the actual type)

Since JSON has no concept of undefined, both .optional() and .exactOptional() should map to "not required" in OpenAPI.

Suggested fix

Update isOptionalSchema to also check for ZodExactOptional:

export function isOptionalSchema(schema: z.ZodType) {
  if (schema.safeParse(undefined).success) return true;
  // ZodExactOptional allows key absence but rejects undefined as a value.
  // In JSON/OpenAPI, this still means the property is not required.
  if ((schema as any)?.def?.type === "exactOptional") return true;
  return false;
}

Environment

  • zod: 4.3.6
  • @asteasolutions/zod-to-openapi: 8.4.1
  • @hono/zod-openapi: 1.2.2
  • exactOptionalPropertyTypes: true in tsconfig.json

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions