Repository navigation
Zod v3: a .nullable().optional() schema shared by two fields is encoded two different ways, and one of them uses not #2462
Description
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
$refintodefinitions, and thatdefinitionsentry
keeps ananyOf: [{ "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.mjsimport { 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
fix(zod): narrow this back to the inconsistency it was filed for
fix(zod): omit impossible optional branch in strict schemas (openai#2751