Skip to content

Commit 1a7b502

Browse files
authored
Support ZodInterface (#2560)
The new recommended way to describe circular schemas and objects having optional properties https://v4.zod.dev/v4#zinterface
1 parent 45c34f0 commit 1a7b502

12 files changed

Lines changed: 193 additions & 31 deletions

express-zod-api/src/io-schema.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@ export const getFinalEndpointInputSchema = <
3636

3737
export const extractObjectSchema = (subject: IOSchema): z.ZodObject => {
3838
if (subject instanceof z.ZodObject) return subject;
39+
if (subject instanceof z.ZodInterface) {
40+
const { optional } = subject._zod.def;
41+
const mask = R.zipObj(optional, Array(optional.length).fill(true));
42+
const partial = subject.pick(mask);
43+
const required = subject.omit(mask);
44+
return z
45+
.object(required._zod.def.shape)
46+
.extend(z.object(partial._zod.def.shape).partial());
47+
}
3948
if (
4049
subject instanceof z.ZodUnion ||
4150
subject instanceof z.ZodDiscriminatedUnion

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface ZTSContext extends FlatObject {
99
schema: $ZodType | (() => $ZodType),
1010
produce: () => ts.TypeNode,
1111
) => ts.TypeNode;
12+
// @todo remove it in favor of z.interface
1213
optionalPropStyle: { withQuestionMark?: boolean; withUndefined?: boolean };
1314
}
1415

express-zod-api/src/zts.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,23 @@ const onLiteral: Producer = ({ _zod: { def } }: $ZodLiteral) => {
6666
return values.length === 1 ? values[0] : f.createUnionTypeNode(values);
6767
};
6868

69+
const onInterface: Producer = (int: z.ZodInterface, { next, makeAlias }) =>
70+
makeAlias(int, () => {
71+
const members = Object.entries(int._zod.def.shape).map<ts.TypeElement>(
72+
([key, value]) => {
73+
const isOptional = int._zod.def.optional.includes(key);
74+
const { description: comment, deprecated: isDeprecated } =
75+
globalRegistry.get(value) || {};
76+
return makeInterfaceProp(key, next(value), {
77+
comment,
78+
isDeprecated,
79+
isOptional,
80+
});
81+
},
82+
);
83+
return f.createTypeLiteralNode(members);
84+
});
85+
6986
const onObject: Producer = (
7087
{ _zod: { def } }: z.ZodObject,
7188
{
@@ -233,6 +250,7 @@ const producers: HandlingRules<
233250
tuple: onTuple,
234251
record: onRecord,
235252
object: onObject,
253+
interface: onInterface,
236254
literal: onLiteral,
237255
intersection: onIntersection,
238256
union: onSomeUnion,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1221,7 +1221,7 @@ servers:
12211221
"
12221222
`;
12231223

1224-
exports[`Documentation > Basic cases > should handle circular schemas via z.lazy() 1`] = `
1224+
exports[`Documentation > Basic cases > should handle circular schemas via z.interface() 1`] = `
12251225
"openapi: 3.1.0
12261226
info:
12271227
title: Testing Lazy

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

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -586,7 +586,76 @@ export type Request = keyof Input;
586586
"
587587
`;
588588
589-
exports[`Integration > Should support types variant and handle recursive schemas 1`] = `
589+
exports[`Integration > Should support types variant and handle recursive schemas 0 1`] = `
590+
"type Type1 = {
591+
name: string;
592+
features: Type1;
593+
};
594+
595+
type SomeOf<T> = T[keyof T];
596+
597+
/** post /v1/test */
598+
type PostV1TestInput = {
599+
features: Type1;
600+
};
601+
602+
/** post /v1/test */
603+
type PostV1TestPositiveVariant1 = {
604+
status: "success";
605+
data: {};
606+
};
607+
608+
/** post /v1/test */
609+
interface PostV1TestPositiveResponseVariants {
610+
200: PostV1TestPositiveVariant1;
611+
}
612+
613+
/** post /v1/test */
614+
type PostV1TestNegativeVariant1 = {
615+
status: "error";
616+
error: {
617+
message: string;
618+
};
619+
};
620+
621+
/** post /v1/test */
622+
interface PostV1TestNegativeResponseVariants {
623+
400: PostV1TestNegativeVariant1;
624+
}
625+
626+
export type Path = "/v1/test";
627+
628+
export type Method = "get" | "post" | "put" | "delete" | "patch";
629+
630+
export interface Input {
631+
/** @deprecated */
632+
"post /v1/test": PostV1TestInput;
633+
}
634+
635+
export interface PositiveResponse {
636+
/** @deprecated */
637+
"post /v1/test": SomeOf<PostV1TestPositiveResponseVariants>;
638+
}
639+
640+
export interface NegativeResponse {
641+
/** @deprecated */
642+
"post /v1/test": SomeOf<PostV1TestNegativeResponseVariants>;
643+
}
644+
645+
export interface EncodedResponse {
646+
/** @deprecated */
647+
"post /v1/test": PostV1TestPositiveResponseVariants & PostV1TestNegativeResponseVariants;
648+
}
649+
650+
export interface Response {
651+
/** @deprecated */
652+
"post /v1/test": PositiveResponse["post /v1/test"] | NegativeResponse["post /v1/test"];
653+
}
654+
655+
export type Request = keyof Input;"
656+
`;
657+
658+
exports[`Integration > Should support types variant and handle recursive schemas 1 1`] = `
590659
"type Type1 = {
591660
name: string;
592661
features: Type1;

express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,23 @@ exports[`I/O Schema and related helpers > extractObjectSchema() > Feature #1869:
2828
}
2929
`;
3030

31+
exports[`I/O Schema and related helpers > extractObjectSchema() > Zod 4 > should handle interfaces with optional props 1`] = `
32+
{
33+
"properties": {
34+
"one": {
35+
"type": "boolean",
36+
},
37+
"two": {
38+
"type": "boolean",
39+
},
40+
},
41+
"required": [
42+
"one",
43+
],
44+
"type": "object",
45+
}
46+
`;
47+
3148
exports[`I/O Schema and related helpers > extractObjectSchema() > Zod 4 > should throw for incompatible ones 1`] = `
3249
IOSchemaError({
3350
"cause": {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ exports[`zod-to-ts > Example > should produce the expected results 1`] = `
99
}[];
1010
boolean: boolean;
1111
circular: SomeType;
12+
circular2: SomeType;
1213
union: {
1314
number: number;
1415
} | "hi";

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -481,11 +481,12 @@ describe("Documentation", () => {
481481
expect(boolean.parse(null)).toBe(false);
482482
});
483483

484-
// @todo switch to z.interface for that
485-
test("should handle circular schemas via z.lazy()", () => {
486-
const category: z.ZodObject = z.object({
484+
test("should handle circular schemas via z.interface()", () => {
485+
const category = z.interface({
487486
name: z.string(),
488-
subcategories: z.lazy(() => category.array()),
487+
get subcategories() {
488+
return z.array(category);
489+
},
489490
});
490491
const spec = new Documentation({
491492
config: sampleConfig,

express-zod-api/tests/env.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,17 @@ describe("Environment checks", () => {
6363
});
6464
});
6565

66+
describe("Zod new features", () => {
67+
test("interface shape does not contain question marks, but there is a list of them", () => {
68+
const schema = z.interface({
69+
one: z.boolean(),
70+
"two?": z.boolean(),
71+
});
72+
expect(Object.keys(schema._zod.def.shape)).toEqual(["one", "two"]);
73+
expect(schema._zod.def.optional).toEqual(["two"]);
74+
});
75+
});
76+
6677
describe("Vitest error comparison", () => {
6778
test("should distinguish error instances of different classes", () => {
6879
expect(createHttpError(500, "some message")).not.toEqual(

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

Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,33 +9,42 @@ import {
99
} from "../src";
1010

1111
describe("Integration", () => {
12-
test("Should support types variant and handle recursive schemas", () => {
13-
const recursiveSchema: z.ZodTypeAny = z.lazy(() =>
14-
z.object({
15-
name: z.string(),
16-
features: recursiveSchema,
17-
}),
18-
);
12+
const recursive1: z.ZodTypeAny = z.lazy(() =>
13+
z.object({
14+
name: z.string(),
15+
features: recursive1,
16+
}),
17+
);
18+
const recursive2 = z.interface({
19+
name: z.string(),
20+
get features() {
21+
return recursive2;
22+
},
23+
});
1924

20-
const client = new Integration({
21-
variant: "types",
22-
routing: {
23-
v1: {
24-
test: defaultEndpointsFactory
25-
.build({
26-
method: "post",
27-
input: z.object({
28-
features: recursiveSchema,
29-
}),
30-
output: z.object({}),
31-
handler: async () => ({}),
32-
})
33-
.deprecated(),
25+
test.each([recursive1, recursive2])(
26+
"Should support types variant and handle recursive schemas %#",
27+
(recursiveSchema) => {
28+
const client = new Integration({
29+
variant: "types",
30+
routing: {
31+
v1: {
32+
test: defaultEndpointsFactory
33+
.build({
34+
method: "post",
35+
input: z.object({
36+
features: recursiveSchema,
37+
}),
38+
output: z.object({}),
39+
handler: async () => ({}),
40+
})
41+
.deprecated(),
42+
},
3443
},
35-
},
36-
});
37-
expect(client.print()).toMatchSnapshot();
38-
});
44+
});
45+
expect(client.print()).toMatchSnapshot();
46+
},
47+
);
3948

4049
test("Should treat optionals the same way as z.infer() by default", async () => {
4150
const client = new Integration({

0 commit comments

Comments
 (0)