Skip to content

Zod v3: a .nullable().optional() schema shared by two fields is encoded two different ways, and one of them uses not #2462

Description

@cmun2

Summary

When one Zod v3 schema object ending in .nullable().optional() is reached
from more than one field, the strict Structured Outputs helpers encode it twice,
differently, in a single document:

  • the first occurrence is inlined and is clean;
  • the second becomes a $ref into definitions, and that definitions entry
    keeps an anyOf: [{ "not": {} }, …] wrapper.

not is not in the JSON Schema subset strict Structured Outputs accepts. The
SDK's own toStrictJsonSchema() rejects it — src/lib/transform.ts lists not
as unsupported and raises "uses unsupported keyword not" — but only the Zod
v4 and Standard Schema helpers call that function. zodV3ToJsonSchema()
(src/helpers/zod.ts) returns its schema without it, so on the v3 path the
wrapper reaches the request body unchecked.

Zod v4 is unaffected: zodV4ToJsonSchema() goes through z4.toJSONSchema() and
never enters the vendored converter.

Reproduction

No API key, no network.

npm init -y
npm i openai@7.5.0 zod@3
node repro.mjs
import { zodResponseFormat } from 'openai/helpers/zod';
import { z } from 'zod/v3';

// `.optional()` on its own is rejected by these helpers with
// "uses `.optional()` without `.nullable()`", so `.nullable().optional()` is the
// accepted shape.
const OptionalName = z.string().nullable().optional();

// One schema object, two fields.
console.log(
  JSON.stringify(
    zodResponseFormat(z.object({ a: OptionalName, b: OptionalName }), 'p').json_schema.schema,
    null,
    2,
  ),
);

Actual:

{
  "type": "object",
  "properties": {
    "a": { "type": "string", "nullable": true },
    "b": { "$ref": "#/definitions/p_properties_a" }
  },
  "required": ["a", "b"],
  "additionalProperties": false,
  "definitions": {
    "p_properties_a": {
      "anyOf": [
        { "not": {} },
        { "type": "string", "nullable": true }
      ]
    },
    "p": { "…": "same as the root" }
  },
  "$schema": "http://json-schema.org/draft-07/schema#"
}

Expected — properties.a and definitions.p_properties_a are the same Zod
node, so they should be the same JSON Schema:

"definitions": {
  "p_properties_a": { "type": "string", "nullable": true }
}

Scope

Measured on openai@7.5.0 (2026-08-17), which already contains the Zod v3
fixes from #2346, #2355, #2357 and #2358.

Affected, in all three of zodResponseFormat, zodTextFormat and
zodFunction:

shape affected
.nullable().optional() string reached from two fields yes
the same, object / array / enum inner type yes
the same, reached from three fields yes
the same, nested one level down yes
supplied through schemaDefinitions (no sharing needed) yes
.nullable() without .optional(), shared no
.nullable().optional() used once no
any other shared schema (plain, object, enum, array, union) no

Also reaches zodResponsesFunction and the non-strict Realtime tool path.

Cause

zodToJsonSchema.ts materializes a definition because some property $refs it,
then parses it with currentPath pointing at the definition but no
propertyPath:

definitions[key] =
  parseDef(
    zodDef(schema),
    { ...refs, currentPath: [...refs.basePath, refs.definitionPath, key] },
    true,
  ) ?? {};

parseOptionalDef (parsers/optional.ts) branches on exactly that field:

if (
  refs.propertyPath &&
  refs.currentPath.slice(0, refs.propertyPath.length).toString() === refs.propertyPath.toString()
) {
  return parseDef(def.innerType._def, { ...refs, currentPath: refs.currentPath }, forceResolution);
}
const innerSchema = parseDef(def.innerType._def, {
  ...refs,
  currentPath: [...refs.currentPath, 'anyOf', '1'],
}, forceResolution);
return innerSchema ? { anyOf: [{ not: {} }, innerSchema] } : {};

Inline, parseObjectDef sets propertyPath and the first branch is taken. In
the definitions loop it is undefined, so the same Zod node takes the second
branch and gains the anyOf/not wrapper.

parseOptionalDef is the only reader of propertyPath in the vendored
converter — grep -rn propertyPath src/_vendor/ returns Refs.ts (declaration),
parsers/object.ts (writer) and parsers/optional.ts (reader) — so this is the
only parser the omission can affect.

Environment

  • openai@7.5.0, zod@3.25.76, Node v23.1.0
  • Also reproduced against main @ 39c06d1

Not verified

No request was sent to the API. The claim that not is unacceptable rests on
the SDK's own toStrictJsonSchema() treating it as unsupported, not on an
observed 400. If the API in fact tolerates anyOf: [{not: {}}, X], the
inconsistency still stands but the severity is lower than stated here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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