Skip to content

fix: keep every value of a multi-value literal in the generated enum - #391

Open
Motoki-sai wants to merge 1 commit into
asteasolutions:masterfrom
Motoki-sai:fix/multi-value-literal-enum
Open

Motoki-sai wants to merge 1 commit into
asteasolutions:masterfrom
Motoki-sai:fix/multi-value-literal-enum

Conversation

@Motoki-sai

Copy link
Copy Markdown

Problem

Since Zod 4 a ZodLiteral can hold multiple values (z.literal([0, 1])), but LiteralTransformer reads only def.values[0]:

const type = typeof zodSchema.def.values[0];
// ...
return { ...mapNullableType(type), enum: [zodSchema.def.values[0]] };

Every value but the first is silently dropped from the generated document, so a schema that accepts 0 or 1 is documented as accepting only 0:

const registry = new OpenAPIRegistry();
registry.registerPath({
  method: 'get',
  path: '/x',
  request: { query: z.object({ isCrossing: z.literal([0, 1]).optional() }) },
  responses: { 200: { description: 'ok' } },
});
isCrossing schema
before { "type": "number", "enum": [0] }
after { "type": "number", "enum": [0, 1] }
z.toJSONSchema (for reference) { "type": "number", "enum": [0, 1] }

It reproduces on master (9.1.0) for both string and number literals, and in 3.0.0, 3.1.0 and 3.2.0 alike. Nothing throws — the document is just wrong, which makes it easy to miss.

I hit this through @hono/zod-openapi, where a query parameter documented as enum: [0] still validated 1 at runtime.

Fix

enum now contains all values, and the type is derived from all of them:

  • values that share a type keep that type (unchanged for single-value literals)
  • values of different types (z.literal([0, 'john'])) get no type, which is what z.toJSONSchema does as well
  • null is excluded from the type computation and left to the nullable mapping, so z.literal([0, null]) stays { type: 'number', nullable: true, enum: [0, null] }
  • bigint values keep going through BigIntTransformer (they cannot be represented as enum values)

One incidental behaviour change: z.literal(null) used to produce { type: 'object', nullable: true, enum: [null] } because typeof null === 'object'. It now produces { nullable: true, enum: [null] } in 3.0.0 and { enum: [null] } in 3.1.0, which matches what z.null() generates. It had no test, and type: 'object' for a null value looked unintended, but happy to restore it if you consider it part of the contract.

Tests

Added spec/types/literal.spec.ts (there was no dedicated spec for literals): single string/number/boolean/bigint values, multiple values per type, nullable, mixed types, and the null literal in 3.0.0 and 3.1.0. npx jest passes in full (52 suites) and npm run test:types is clean.

Since Zod 4 a ZodLiteral can hold multiple values (z.literal([0, 1])), but the
transformer only emitted def.values[0], so every value but the first was
silently dropped from the generated document.

The type is now derived from all values: it is omitted when they do not share
one, and `null` is left to the nullable mapping, which also makes
z.literal(null) consistent with z.null().
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants