Skip to content
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 4 additions & 4 deletions example/example.client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
type Type1 = {
title: string;
features?: Type1[] | undefined;
features?: Type1[];
};

type SomeOf<T> = T[keyof T];
Expand Down Expand Up @@ -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 */
Expand Down
31 changes: 17 additions & 14 deletions express-zod-api/src/common-helpers.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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) =>
Expand Down
7 changes: 3 additions & 4 deletions express-zod-api/src/documentation-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 };
};
Expand Down Expand Up @@ -415,8 +416,6 @@ const depicters: Partial<Record<FirstPartyKind | ProprietaryBrand, Depicter>> =

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",
Expand Down
12 changes: 3 additions & 9 deletions express-zod-api/src/zts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -77,15 +77,12 @@ const onObject: Producer = (
const fn = () => {
const members = Object.entries(obj._zod.def.shape).map<ts.TypeElement>(
([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 }),
});
},
);
Expand Down Expand Up @@ -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)]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -250,7 +265,9 @@ exports[`Documentation helpers > depictObject() > should remove optional props f
"type": "string",
},
},
"required": [],
"required": [
"b",
],
"type": "object",
}
`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2147,7 +2147,7 @@ paths:
parameters:
- name: any
in: query
required: false
required: true
description: GET /v1/getSomething Parameter
schema: {}
responses:
Expand Down Expand Up @@ -2224,7 +2224,7 @@ paths:
parameters:
- name: string
in: query
required: false
required: true
description: GET /v1/getSomething Parameter
schema:
format: string (preprocessed)
Expand Down
4 changes: 2 additions & 2 deletions express-zod-api/tests/__snapshots__/integration.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
};
Expand Down
60 changes: 30 additions & 30 deletions express-zod-api/tests/__snapshots__/zts.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: {
Expand All @@ -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;
}"
Expand Down Expand Up @@ -127,15 +127,15 @@ exports[`zod-to-ts > Issue #2352: intersection of objects having same prop %# >
"{
query: string;
} & {
query?: string | undefined;
query?: string;
}"
`;

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`] = `
"{
query: string;
} & {
query?: string | undefined;
query?: string;
}"
`;

Expand All @@ -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;
}"
`;
Expand Down Expand Up @@ -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;
}"
`;

Expand Down Expand Up @@ -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;
];
}"
`;
16 changes: 12 additions & 4 deletions express-zod-api/tests/documentation-helpers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
},
},
])(
Expand Down
Loading