diff --git a/CHANGELOG.md b/CHANGELOG.md index 995f972b0..0b99bf85b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,11 @@ - The `numericRange` option removed from `Documentation` class constructor argument; - The `brandHandling` should consist of postprocessing functions altering the depiction made by Zod 4; - The `Depicter` type signature changed; -- The `optionalPropStyle` option removed from `Integration` class constructor. +- The `optionalPropStyle` option removed from `Integration` class constructor: + - Use `.optional()` to add question mark to the object property; + - Use `.or(z.undefined())` to add `undefined` to the type of the object property; + - Reasoning: https://x.com/colinhacks/status/1919292504861491252; + - `z.any()` and `z.unknown()` are not optional, details: https://v4.zod.dev/v4/changelog#changes-zunknown-optionality. - Changes to the plugin: - Brand is the only kind of metadata that withstands refinements and checks. diff --git a/example/example.client.ts b/example/example.client.ts index 61a7d056b..d3e9c7c65 100644 --- a/example/example.client.ts +++ b/example/example.client.ts @@ -1,6 +1,6 @@ type Type1 = { title: string; - features?: Type1[] | undefined; + features?: Type1[]; }; type SomeOf = T[keyof T]; @@ -265,15 +265,15 @@ interface PostV1AvatarRawNegativeResponseVariants { /** get /v1/events/stream */ type GetV1EventsStreamInput = { /** @deprecated for testing error response */ - trigger?: string | undefined; + trigger?: string; }; /** get /v1/events/stream */ type GetV1EventsStreamPositiveVariant1 = { data: number; event: "time"; - id?: string | undefined; - retry?: number | undefined; + id?: string; + retry?: number; }; /** get /v1/events/stream */ diff --git a/express-zod-api/src/common-helpers.ts b/express-zod-api/src/common-helpers.ts index 5da0a03ad..991351170 100644 --- a/express-zod-api/src/common-helpers.ts +++ b/express-zod-api/src/common-helpers.ts @@ -1,4 +1,9 @@ -import type { $ZodObject, $ZodTransform, $ZodType } from "@zod/core"; +import type { + $ZodObject, + $ZodTransform, + $ZodType, + $ZodTypeInternals, +} from "@zod/core"; import { Request } from "express"; import * as R from "ramda"; import { globalRegistry, z } from "zod"; @@ -170,19 +175,17 @@ export const getTransformedType = R.tryCatch( R.always(undefined), ); -/** - * @link https://github.com/colinhacks/zod/issues/4159 - * @todo replace undefined check with using using ._zod.optionality - * @see https://github.com/RobinTail/express-zod-api/pull/2600/files#r2073174475 - * @link https://v4.zod.dev/v4/changelog#changes-zunknown-optionality - * */ -export const doesAccept = R.tryCatch( - (schema: $ZodType, value: undefined | null) => { - z.parse(schema, value); - return true; - }, - R.always(false), -); +const requestOptionality: Array<$ZodTypeInternals["optionality"]> = [ + "optional", + "defaulted", +]; +export const isOptional = ( + { _zod: { optionality } }: $ZodType, + { isResponse }: { isResponse: boolean }, +) => + isResponse + ? optionality === "optional" + : optionality && requestOptionality.includes(optionality); /** @desc can still be an array, use Array.isArray() or rather R.type() to exclude that case */ export const isObject = (subject: unknown) => diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 152e7258d..dfd03fb23 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -26,12 +26,12 @@ import * as R from "ramda"; import { globalRegistry, z } from "zod"; import { ResponseVariant } from "./api-response"; import { - doesAccept, FlatObject, getExamples, getRoutePathParams, getTransformedType, isObject, + isOptional, isSchema, makeCleanId, routePathParamsRegex, @@ -174,7 +174,8 @@ export const depictObject: Depicter = ( const result: string[] = []; for (const key of required) { const valueSchema = zodSchema._zod.def.shape[key]; - if (valueSchema && !doesAccept(valueSchema, undefined)) result.push(key); + if (valueSchema && !isOptional(valueSchema, { isResponse })) + result.push(key); } return { ...jsonSchema, required: result }; }; @@ -415,8 +416,6 @@ const depicters: Partial> = const onEach: Depicter = ({ zodSchema, jsonSchema }, { isResponse }) => { const result = { ...jsonSchema }; - if (!isResponse && doesAccept(zodSchema, null)) - Object.assign(result, { type: makeNullableType(jsonSchema.type) }); const examples = getExamples({ schema: zodSchema, variant: isResponse ? "parsed" : "original", diff --git a/express-zod-api/src/zts.ts b/express-zod-api/src/zts.ts index c1a4f8c15..cc890007d 100644 --- a/express-zod-api/src/zts.ts +++ b/express-zod-api/src/zts.ts @@ -21,7 +21,7 @@ import type { import * as R from "ramda"; import ts from "typescript"; import { globalRegistry, z } from "zod"; -import { doesAccept, getTransformedType, isSchema } from "./common-helpers"; +import { getTransformedType, isOptional, isSchema } from "./common-helpers"; import { ezDateInBrand } from "./date-in-schema"; import { ezDateOutBrand } from "./date-out-schema"; import { hasCycle } from "./deep-checks"; @@ -77,15 +77,12 @@ const onObject: Producer = ( const fn = () => { const members = Object.entries(obj._zod.def.shape).map( ([key, value]) => { - const isOptional = isResponse - ? isSchema<$ZodOptional>(value, "optional") - : doesAccept(value, undefined); const { description: comment, deprecated: isDeprecated } = globalRegistry.get(value) || {}; return makeInterfaceProp(key, next(value), { comment, isDeprecated, - isOptional, + isOptional: isOptional(value, { isResponse }), }); }, ); @@ -118,10 +115,7 @@ const makeSample = (produced: ts.TypeNode) => samples?.[produced.kind as keyof typeof samples]; const onOptional: Producer = ({ _zod: { def } }: $ZodOptional, { next }) => - f.createUnionTypeNode([ - next(def.innerType), - ensureTypeNode(ts.SyntaxKind.UndefinedKeyword), - ]); + next(def.innerType); const onNullable: Producer = ({ _zod: { def } }: $ZodNullable, { next }) => f.createUnionTypeNode([next(def.innerType), makeLiteralType(null)]); diff --git a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap index d4657b06d..367203042 100644 --- a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap @@ -216,12 +216,27 @@ exports[`Documentation helpers > depictNullable() > should add null type to the } `; -exports[`Documentation helpers > depictNullable() > should not add null type when it's already there 1`] = ` +exports[`Documentation helpers > depictNullable() > should not add null type when it's already there 0 1`] = ` { "type": "null", } `; +exports[`Documentation helpers > depictNullable() > should not add null type when it's already there 1 1`] = ` +{ + "type": "null", +} +`; + +exports[`Documentation helpers > depictNullable() > should not add null type when it's already there 2 1`] = ` +{ + "type": [ + "string", + "null", + ], +} +`; + exports[`Documentation helpers > depictObject() > should remove optional props from required for request 0 1`] = ` { "properties": { @@ -250,7 +265,9 @@ exports[`Documentation helpers > depictObject() > should remove optional props f "type": "string", }, }, - "required": [], + "required": [ + "b", + ], "type": "object", } `; diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index cb45adc06..f789b9d7f 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -2147,7 +2147,7 @@ paths: parameters: - name: any in: query - required: false + required: true description: GET /v1/getSomething Parameter schema: {} responses: @@ -2224,7 +2224,7 @@ paths: parameters: - name: string in: query - required: false + required: true description: GET /v1/getSomething Parameter schema: format: string (preprocessed) diff --git a/express-zod-api/tests/__snapshots__/integration.spec.ts.snap b/express-zod-api/tests/__snapshots__/integration.spec.ts.snap index 38e79e133..4e4a2c452 100644 --- a/express-zod-api/tests/__snapshots__/integration.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/integration.spec.ts.snap @@ -282,14 +282,14 @@ exports[`Integration > Should treat optionals the same way as z.infer() by defau /** post /v1/test-with-dashes */ type PostV1TestWithDashesInput = { - opt?: string | undefined; + opt?: string; }; /** post /v1/test-with-dashes */ type PostV1TestWithDashesPositiveVariant1 = { status: "success"; data: { - similar?: number | undefined; + similar?: number; }; }; diff --git a/express-zod-api/tests/__snapshots__/zts.spec.ts.snap b/express-zod-api/tests/__snapshots__/zts.spec.ts.snap index 4213a12e0..c0035bf4b 100644 --- a/express-zod-api/tests/__snapshots__/zts.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/zts.spec.ts.snap @@ -16,20 +16,20 @@ exports[`zod-to-ts > Example > should produce the expected results 1`] = ` enum: "hi" | "bye"; intersectionWithTransform: (number & bigint) & (number & string); date: any; - undefined?: undefined; + undefined: undefined; null: null; - void?: any; - any?: any; - unknown?: any; + void: any; + any: any; + unknown: any; never: any; - optionalString?: string | undefined; + optionalString?: string; nullablePartialObject: { - string?: string | undefined; - number?: number | undefined; - fixedArrayOfString?: string[] | undefined; + string?: string; + number?: number; + fixedArrayOfString?: string[]; object?: { string: string; - } | undefined; + }; } | null; tuple: [ string, @@ -57,8 +57,8 @@ exports[`zod-to-ts > Example > should produce the expected results 1`] = ` set: any; intersection: (string & number) | bigint; promise: any; - optDefaultString?: string | undefined; - refinedStringWithSomeBullshit: (string | number) & ((bigint | null) | undefined); + optDefaultString?: string; + refinedStringWithSomeBullshit: (string | number) & (bigint | null); nativeEnum: "A" | "apple" | "banana" | "cantaloupe" | 5; lazy: SomeType; discUnion: { @@ -73,7 +73,7 @@ exports[`zod-to-ts > Example > should produce the expected results 1`] = ` y: number; }; branded: string; - catch?: number; + catch: number; pipeline: string; readonly: string; }" @@ -127,7 +127,7 @@ exports[`zod-to-ts > Issue #2352: intersection of objects having same prop %# > "{ query: string; } & { - query?: string | undefined; + query?: string; }" `; @@ -135,7 +135,7 @@ exports[`zod-to-ts > Issue #2352: intersection of objects having same prop %# > "{ query: string; } & { - query?: string | undefined; + query?: string; }" `; @@ -145,11 +145,11 @@ exports[`zod-to-ts > PrimitiveSchema > outputs correct typescript 1`] = ` number: number; boolean: boolean; date: any; - undefined?: undefined; + undefined: undefined; null: null; - void?: any; - any?: any; - unknown?: any; + void: any; + any: any; + unknown: any; never: any; }" `; @@ -230,10 +230,10 @@ exports[`zod-to-ts > z.object() > escapes correctly 1`] = ` "'": string; "\`": string; "\\n": number; - $e?: any; - "4t"?: any; - _r?: any; - "-r"?: undefined; + $e: any; + "4t": any; + _r: any; + "-r": undefined; }" `; @@ -268,21 +268,21 @@ exports[`zod-to-ts > z.object() > supports zod.describe() 1`] = ` }" `; -exports[`zod-to-ts > z.optional() > outputs correct typescript 1`] = `"string | undefined"`; +exports[`zod-to-ts > z.optional() > Zod 4: does not add undefined to it, unwrap as is 1`] = `"string"`; -exports[`zod-to-ts > z.optional() > should output \`?:\` and undefined union for optional properties 1`] = ` +exports[`zod-to-ts > z.optional() > Zod 4: should add question mark only to optional props 1`] = ` "{ - optional?: string | undefined; + optional?: string; required: string; - transform?: number | undefined; - or?: (number | undefined) | string; + transform: number; + or: number | string; tuple?: [ - string | undefined, + string, number, { - optional?: string | undefined; + optional?: string; required: string; } - ] | undefined; + ]; }" `; diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index b09e553bc..90a3c6d9f 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -309,10 +309,18 @@ describe("Documentation helpers", () => { }, ); - test("should not add null type when it's already there", () => { - const jsonSchema: JSONSchema.BaseSchema = { + test.each([ + { type: "null" }, + { anyOf: [{ type: "null" }, { type: "null" }], - }; + }, + { + anyOf: [ + { type: ["string", "null"] as unknown as string }, // nullable of nullable case + { type: "null" }, + ], + }, + ])("should not add null type when it's already there %#", (jsonSchema) => { expect( depictNullable({ zodSchema: z.never(), jsonSchema }, requestCtx), ).toMatchSnapshot(); @@ -356,7 +364,7 @@ describe("Documentation helpers", () => { jsonSchema: { type: "object", properties: { a: { type: "number" }, b: { type: "string" } }, - required: ["b"], + required: ["b"], // Zod 4: coerce remains }, }, ])( diff --git a/express-zod-api/tests/zts.spec.ts b/express-zod-api/tests/zts.spec.ts index 7bc689cd8..e1649ac48 100644 --- a/express-zod-api/tests/zts.spec.ts +++ b/express-zod-api/tests/zts.spec.ts @@ -202,12 +202,16 @@ describe("zod-to-ts", () => { .optional(), }); - test("outputs correct typescript", () => { + test("Zod 4: does not add undefined to it, unwrap as is", () => { const node = zodToTs(optionalStringSchema, { ctx }); expect(printNodeTest(node)).toMatchSnapshot(); }); - test("should output `?:` and undefined union for optional properties", () => { + /** + * @todo revisit when optional+transform fixed + * @link https://github.com/colinhacks/zod/issues/4322 + * */ + test("Zod 4: should add question mark only to optional props", () => { const node = zodToTs(objectWithOptionals, { ctx }); expect(printNodeTest(node)).toMatchSnapshot(); });