Skip to content

Commit 54d841b

Browse files
authored
v24: Utilize optionality (#2604)
All properties are now required in Zod 4 unless they are `.optional()` or `.default()`. That fact is now reflected by `._zod.optionality` Direction matters. External bugs: - colinhacks/zod#4322
1 parent aab3814 commit 54d841b

File tree

11 files changed

+103
-74
lines changed

11 files changed

+103
-74
lines changed

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616
- The `numericRange` option removed from `Documentation` class constructor argument;
1717
- The `brandHandling` should consist of postprocessing functions altering the depiction made by Zod 4;
1818
- The `Depicter` type signature changed;
19-
- The `optionalPropStyle` option removed from `Integration` class constructor.
19+
- The `optionalPropStyle` option removed from `Integration` class constructor:
20+
- Use `.optional()` to add question mark to the object property;
21+
- Use `.or(z.undefined())` to add `undefined` to the type of the object property;
22+
- Reasoning: https://x.com/colinhacks/status/1919292504861491252;
23+
- `z.any()` and `z.unknown()` are not optional, details: https://v4.zod.dev/v4/changelog#changes-zunknown-optionality.
2024
- Changes to the plugin:
2125
- Brand is the only kind of metadata that withstands refinements and checks.
2226

example/example.client.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
type Type1 = {
22
title: string;
3-
features?: Type1[] | undefined;
3+
features?: Type1[];
44
};
55

66
type SomeOf<T> = T[keyof T];
@@ -265,15 +265,15 @@ interface PostV1AvatarRawNegativeResponseVariants {
265265
/** get /v1/events/stream */
266266
type GetV1EventsStreamInput = {
267267
/** @deprecated for testing error response */
268-
trigger?: string | undefined;
268+
trigger?: string;
269269
};
270270

271271
/** get /v1/events/stream */
272272
type GetV1EventsStreamPositiveVariant1 = {
273273
data: number;
274274
event: "time";
275-
id?: string | undefined;
276-
retry?: number | undefined;
275+
id?: string;
276+
retry?: number;
277277
};
278278

279279
/** get /v1/events/stream */

express-zod-api/src/common-helpers.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import type { $ZodObject, $ZodTransform, $ZodType } from "@zod/core";
1+
import type {
2+
$ZodObject,
3+
$ZodTransform,
4+
$ZodType,
5+
$ZodTypeInternals,
6+
} from "@zod/core";
27
import { Request } from "express";
38
import * as R from "ramda";
49
import { globalRegistry, z } from "zod";
@@ -170,19 +175,17 @@ export const getTransformedType = R.tryCatch(
170175
R.always(undefined),
171176
);
172177

173-
/**
174-
* @link https://github.com/colinhacks/zod/issues/4159
175-
* @todo replace undefined check with using using ._zod.optionality
176-
* @see https://github.com/RobinTail/express-zod-api/pull/2600/files#r2073174475
177-
* @link https://v4.zod.dev/v4/changelog#changes-zunknown-optionality
178-
* */
179-
export const doesAccept = R.tryCatch(
180-
(schema: $ZodType, value: undefined | null) => {
181-
z.parse(schema, value);
182-
return true;
183-
},
184-
R.always(false),
185-
);
178+
const requestOptionality: Array<$ZodTypeInternals["optionality"]> = [
179+
"optional",
180+
"defaulted",
181+
];
182+
export const isOptional = (
183+
{ _zod: { optionality } }: $ZodType,
184+
{ isResponse }: { isResponse: boolean },
185+
) =>
186+
isResponse
187+
? optionality === "optional"
188+
: optionality && requestOptionality.includes(optionality);
186189

187190
/** @desc can still be an array, use Array.isArray() or rather R.type() to exclude that case */
188191
export const isObject = (subject: unknown) =>

express-zod-api/src/documentation-helpers.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,12 @@ import * as R from "ramda";
2626
import { globalRegistry, z } from "zod";
2727
import { ResponseVariant } from "./api-response";
2828
import {
29-
doesAccept,
3029
FlatObject,
3130
getExamples,
3231
getRoutePathParams,
3332
getTransformedType,
3433
isObject,
34+
isOptional,
3535
isSchema,
3636
makeCleanId,
3737
routePathParamsRegex,
@@ -174,7 +174,8 @@ export const depictObject: Depicter = (
174174
const result: string[] = [];
175175
for (const key of required) {
176176
const valueSchema = zodSchema._zod.def.shape[key];
177-
if (valueSchema && !doesAccept(valueSchema, undefined)) result.push(key);
177+
if (valueSchema && !isOptional(valueSchema, { isResponse }))
178+
result.push(key);
178179
}
179180
return { ...jsonSchema, required: result };
180181
};
@@ -415,8 +416,6 @@ const depicters: Partial<Record<FirstPartyKind | ProprietaryBrand, Depicter>> =
415416

416417
const onEach: Depicter = ({ zodSchema, jsonSchema }, { isResponse }) => {
417418
const result = { ...jsonSchema };
418-
if (!isResponse && doesAccept(zodSchema, null))
419-
Object.assign(result, { type: makeNullableType(jsonSchema.type) });
420419
const examples = getExamples({
421420
schema: zodSchema,
422421
variant: isResponse ? "parsed" : "original",

express-zod-api/src/zts.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import type {
2121
import * as R from "ramda";
2222
import ts from "typescript";
2323
import { globalRegistry, z } from "zod";
24-
import { doesAccept, getTransformedType, isSchema } from "./common-helpers";
24+
import { getTransformedType, isOptional, isSchema } from "./common-helpers";
2525
import { ezDateInBrand } from "./date-in-schema";
2626
import { ezDateOutBrand } from "./date-out-schema";
2727
import { hasCycle } from "./deep-checks";
@@ -77,15 +77,12 @@ const onObject: Producer = (
7777
const fn = () => {
7878
const members = Object.entries(obj._zod.def.shape).map<ts.TypeElement>(
7979
([key, value]) => {
80-
const isOptional = isResponse
81-
? isSchema<$ZodOptional>(value, "optional")
82-
: doesAccept(value, undefined);
8380
const { description: comment, deprecated: isDeprecated } =
8481
globalRegistry.get(value) || {};
8582
return makeInterfaceProp(key, next(value), {
8683
comment,
8784
isDeprecated,
88-
isOptional,
85+
isOptional: isOptional(value, { isResponse }),
8986
});
9087
},
9188
);
@@ -118,10 +115,7 @@ const makeSample = (produced: ts.TypeNode) =>
118115
samples?.[produced.kind as keyof typeof samples];
119116

120117
const onOptional: Producer = ({ _zod: { def } }: $ZodOptional, { next }) =>
121-
f.createUnionTypeNode([
122-
next(def.innerType),
123-
ensureTypeNode(ts.SyntaxKind.UndefinedKeyword),
124-
]);
118+
next(def.innerType);
125119

126120
const onNullable: Producer = ({ _zod: { def } }: $ZodNullable, { next }) =>
127121
f.createUnionTypeNode([next(def.innerType), makeLiteralType(null)]);

express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,12 +216,27 @@ exports[`Documentation helpers > depictNullable() > should add null type to the
216216
}
217217
`;
218218

219-
exports[`Documentation helpers > depictNullable() > should not add null type when it's already there 1`] = `
219+
exports[`Documentation helpers > depictNullable() > should not add null type when it's already there 0 1`] = `
220220
{
221221
"type": "null",
222222
}
223223
`;
224224

225+
exports[`Documentation helpers > depictNullable() > should not add null type when it's already there 1 1`] = `
226+
{
227+
"type": "null",
228+
}
229+
`;
230+
231+
exports[`Documentation helpers > depictNullable() > should not add null type when it's already there 2 1`] = `
232+
{
233+
"type": [
234+
"string",
235+
"null",
236+
],
237+
}
238+
`;
239+
225240
exports[`Documentation helpers > depictObject() > should remove optional props from required for request 0 1`] = `
226241
{
227242
"properties": {
@@ -250,7 +265,9 @@ exports[`Documentation helpers > depictObject() > should remove optional props f
250265
"type": "string",
251266
},
252267
},
253-
"required": [],
268+
"required": [
269+
"b",
270+
],
254271
"type": "object",
255272
}
256273
`;

express-zod-api/tests/__snapshots__/documentation.spec.ts.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2147,7 +2147,7 @@ paths:
21472147
parameters:
21482148
- name: any
21492149
in: query
2150-
required: false
2150+
required: true
21512151
description: GET /v1/getSomething Parameter
21522152
schema: {}
21532153
responses:
@@ -2224,7 +2224,7 @@ paths:
22242224
parameters:
22252225
- name: string
22262226
in: query
2227-
required: false
2227+
required: true
22282228
description: GET /v1/getSomething Parameter
22292229
schema:
22302230
format: string (preprocessed)

express-zod-api/tests/__snapshots__/integration.spec.ts.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,14 +282,14 @@ exports[`Integration > Should treat optionals the same way as z.infer() by defau
282282
283283
/** post /v1/test-with-dashes */
284284
type PostV1TestWithDashesInput = {
285-
opt?: string | undefined;
285+
opt?: string;
286286
};
287287
288288
/** post /v1/test-with-dashes */
289289
type PostV1TestWithDashesPositiveVariant1 = {
290290
status: "success";
291291
data: {
292-
similar?: number | undefined;
292+
similar?: number;
293293
};
294294
};
295295

express-zod-api/tests/__snapshots__/zts.spec.ts.snap

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,20 @@ exports[`zod-to-ts > Example > should produce the expected results 1`] = `
1616
enum: "hi" | "bye";
1717
intersectionWithTransform: (number & bigint) & (number & string);
1818
date: any;
19-
undefined?: undefined;
19+
undefined: undefined;
2020
null: null;
21-
void?: any;
22-
any?: any;
23-
unknown?: any;
21+
void: any;
22+
any: any;
23+
unknown: any;
2424
never: any;
25-
optionalString?: string | undefined;
25+
optionalString?: string;
2626
nullablePartialObject: {
27-
string?: string | undefined;
28-
number?: number | undefined;
29-
fixedArrayOfString?: string[] | undefined;
27+
string?: string;
28+
number?: number;
29+
fixedArrayOfString?: string[];
3030
object?: {
3131
string: string;
32-
} | undefined;
32+
};
3333
} | null;
3434
tuple: [
3535
string,
@@ -57,8 +57,8 @@ exports[`zod-to-ts > Example > should produce the expected results 1`] = `
5757
set: any;
5858
intersection: (string & number) | bigint;
5959
promise: any;
60-
optDefaultString?: string | undefined;
61-
refinedStringWithSomeBullshit: (string | number) & ((bigint | null) | undefined);
60+
optDefaultString?: string;
61+
refinedStringWithSomeBullshit: (string | number) & (bigint | null);
6262
nativeEnum: "A" | "apple" | "banana" | "cantaloupe" | 5;
6363
lazy: SomeType;
6464
discUnion: {
@@ -73,7 +73,7 @@ exports[`zod-to-ts > Example > should produce the expected results 1`] = `
7373
y: number;
7474
};
7575
branded: string;
76-
catch?: number;
76+
catch: number;
7777
pipeline: string;
7878
readonly: string;
7979
}"
@@ -127,15 +127,15 @@ exports[`zod-to-ts > Issue #2352: intersection of objects having same prop %# >
127127
"{
128128
query: string;
129129
} & {
130-
query?: string | undefined;
130+
query?: string;
131131
}"
132132
`;
133133

134134
exports[`zod-to-ts > Issue #2352: intersection of objects having same prop %# > should not flatten the result for objects with a conflicting prop 3 1`] = `
135135
"{
136136
query: string;
137137
} & {
138-
query?: string | undefined;
138+
query?: string;
139139
}"
140140
`;
141141

@@ -145,11 +145,11 @@ exports[`zod-to-ts > PrimitiveSchema > outputs correct typescript 1`] = `
145145
number: number;
146146
boolean: boolean;
147147
date: any;
148-
undefined?: undefined;
148+
undefined: undefined;
149149
null: null;
150-
void?: any;
151-
any?: any;
152-
unknown?: any;
150+
void: any;
151+
any: any;
152+
unknown: any;
153153
never: any;
154154
}"
155155
`;
@@ -230,10 +230,10 @@ exports[`zod-to-ts > z.object() > escapes correctly 1`] = `
230230
"'": string;
231231
"\`": string;
232232
"\\n": number;
233-
$e?: any;
234-
"4t"?: any;
235-
_r?: any;
236-
"-r"?: undefined;
233+
$e: any;
234+
"4t": any;
235+
_r: any;
236+
"-r": undefined;
237237
}"
238238
`;
239239

@@ -268,21 +268,21 @@ exports[`zod-to-ts > z.object() > supports zod.describe() 1`] = `
268268
}"
269269
`;
270270

271-
exports[`zod-to-ts > z.optional() > outputs correct typescript 1`] = `"string | undefined"`;
271+
exports[`zod-to-ts > z.optional() > Zod 4: does not add undefined to it, unwrap as is 1`] = `"string"`;
272272

273-
exports[`zod-to-ts > z.optional() > should output \`?:\` and undefined union for optional properties 1`] = `
273+
exports[`zod-to-ts > z.optional() > Zod 4: should add question mark only to optional props 1`] = `
274274
"{
275-
optional?: string | undefined;
275+
optional?: string;
276276
required: string;
277-
transform?: number | undefined;
278-
or?: (number | undefined) | string;
277+
transform: number;
278+
or: number | string;
279279
tuple?: [
280-
string | undefined,
280+
string,
281281
number,
282282
{
283-
optional?: string | undefined;
283+
optional?: string;
284284
required: string;
285285
}
286-
] | undefined;
286+
];
287287
}"
288288
`;

express-zod-api/tests/documentation-helpers.spec.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -309,10 +309,18 @@ describe("Documentation helpers", () => {
309309
},
310310
);
311311

312-
test("should not add null type when it's already there", () => {
313-
const jsonSchema: JSONSchema.BaseSchema = {
312+
test.each([
313+
{ type: "null" },
314+
{
314315
anyOf: [{ type: "null" }, { type: "null" }],
315-
};
316+
},
317+
{
318+
anyOf: [
319+
{ type: ["string", "null"] as unknown as string }, // nullable of nullable case
320+
{ type: "null" },
321+
],
322+
},
323+
])("should not add null type when it's already there %#", (jsonSchema) => {
316324
expect(
317325
depictNullable({ zodSchema: z.never(), jsonSchema }, requestCtx),
318326
).toMatchSnapshot();
@@ -356,7 +364,7 @@ describe("Documentation helpers", () => {
356364
jsonSchema: {
357365
type: "object",
358366
properties: { a: { type: "number" }, b: { type: "string" } },
359-
required: ["b"],
367+
required: ["b"], // Zod 4: coerce remains
360368
},
361369
},
362370
])(

0 commit comments

Comments
 (0)