Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions express-zod-api/src/io-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ export const getFinalEndpointInputSchema = <

export const extractObjectSchema = (subject: IOSchema): z.ZodObject => {
if (subject instanceof z.ZodObject) return subject;
if (subject instanceof z.ZodInterface) {
const { optional } = subject._zod.def;
const mask = R.zipObj(optional, Array(optional.length).fill(true));
const partial = subject.pick(mask);
const required = subject.omit(mask);
return z
.object(required._zod.def.shape)
.extend(z.object(partial._zod.def.shape).partial());
}
if (
subject instanceof z.ZodUnion ||
subject instanceof z.ZodDiscriminatedUnion
Expand Down
1 change: 1 addition & 0 deletions express-zod-api/src/zts-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface ZTSContext extends FlatObject {
schema: $ZodType | (() => $ZodType),
produce: () => ts.TypeNode,
) => ts.TypeNode;
// @todo remove it in favor of z.interface
optionalPropStyle: { withQuestionMark?: boolean; withUndefined?: boolean };
}

Expand Down
18 changes: 18 additions & 0 deletions express-zod-api/src/zts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,23 @@ const onLiteral: Producer = ({ _zod: { def } }: $ZodLiteral) => {
return values.length === 1 ? values[0] : f.createUnionTypeNode(values);
};

const onInterface: Producer = (int: z.ZodInterface, { next, makeAlias }) =>
makeAlias(int, () => {
const members = Object.entries(int._zod.def.shape).map<ts.TypeElement>(
([key, value]) => {
const isOptional = int._zod.def.optional.includes(key);
const { description: comment, deprecated: isDeprecated } =
globalRegistry.get(value) || {};
return makeInterfaceProp(key, next(value), {
comment,
isDeprecated,
isOptional,
});
},
);
return f.createTypeLiteralNode(members);
});

const onObject: Producer = (
{ _zod: { def } }: z.ZodObject,
{
Expand Down Expand Up @@ -233,6 +250,7 @@ const producers: HandlingRules<
tuple: onTuple,
record: onRecord,
object: onObject,
interface: onInterface,
literal: onLiteral,
intersection: onIntersection,
union: onSomeUnion,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1221,7 +1221,7 @@ servers:
"
`;

exports[`Documentation > Basic cases > should handle circular schemas via z.lazy() 1`] = `
exports[`Documentation > Basic cases > should handle circular schemas via z.interface() 1`] = `
"openapi: 3.1.0
info:
title: Testing Lazy
Expand Down
71 changes: 70 additions & 1 deletion express-zod-api/tests/__snapshots__/integration.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -586,7 +586,76 @@ export type Request = keyof Input;
"
`;

exports[`Integration > Should support types variant and handle recursive schemas 1`] = `
exports[`Integration > Should support types variant and handle recursive schemas 0 1`] = `
"type Type1 = {
name: string;
features: Type1;
};

type SomeOf<T> = T[keyof T];

/** post /v1/test */
type PostV1TestInput = {
features: Type1;
};

/** post /v1/test */
type PostV1TestPositiveVariant1 = {
status: "success";
data: {};
};

/** post /v1/test */
interface PostV1TestPositiveResponseVariants {
200: PostV1TestPositiveVariant1;
}

/** post /v1/test */
type PostV1TestNegativeVariant1 = {
status: "error";
error: {
message: string;
};
};

/** post /v1/test */
interface PostV1TestNegativeResponseVariants {
400: PostV1TestNegativeVariant1;
}

export type Path = "/v1/test";

export type Method = "get" | "post" | "put" | "delete" | "patch";

export interface Input {
/** @deprecated */
"post /v1/test": PostV1TestInput;
}

export interface PositiveResponse {
/** @deprecated */
"post /v1/test": SomeOf<PostV1TestPositiveResponseVariants>;
}

export interface NegativeResponse {
/** @deprecated */
"post /v1/test": SomeOf<PostV1TestNegativeResponseVariants>;
}

export interface EncodedResponse {
/** @deprecated */
"post /v1/test": PostV1TestPositiveResponseVariants & PostV1TestNegativeResponseVariants;
}

export interface Response {
/** @deprecated */
"post /v1/test": PositiveResponse["post /v1/test"] | NegativeResponse["post /v1/test"];
}

export type Request = keyof Input;"
`;

exports[`Integration > Should support types variant and handle recursive schemas 1 1`] = `
"type Type1 = {
name: string;
features: Type1;
Expand Down
17 changes: 17 additions & 0 deletions express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,23 @@ exports[`I/O Schema and related helpers > extractObjectSchema() > Feature #1869:
}
`;

exports[`I/O Schema and related helpers > extractObjectSchema() > Zod 4 > should handle interfaces with optional props 1`] = `
{
"properties": {
"one": {
"type": "boolean",
},
"two": {
"type": "boolean",
},
},
"required": [
"one",
],
"type": "object",
}
`;

exports[`I/O Schema and related helpers > extractObjectSchema() > Zod 4 > should throw for incompatible ones 1`] = `
IOSchemaError({
"cause": {
Expand Down
1 change: 1 addition & 0 deletions express-zod-api/tests/__snapshots__/zts.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ exports[`zod-to-ts > Example > should produce the expected results 1`] = `
}[];
boolean: boolean;
circular: SomeType;
circular2: SomeType;
union: {
number: number;
} | "hi";
Expand Down
9 changes: 5 additions & 4 deletions express-zod-api/tests/documentation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,11 +481,12 @@ describe("Documentation", () => {
expect(boolean.parse(null)).toBe(false);
});

// @todo switch to z.interface for that
test("should handle circular schemas via z.lazy()", () => {
const category: z.ZodObject = z.object({
test("should handle circular schemas via z.interface()", () => {
const category = z.interface({
name: z.string(),
subcategories: z.lazy(() => category.array()),
get subcategories() {
return z.array(category);
},
});
const spec = new Documentation({
config: sampleConfig,
Expand Down
11 changes: 11 additions & 0 deletions express-zod-api/tests/env.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ describe("Environment checks", () => {
});
});

describe("Zod new features", () => {
test("interface shape does not contain question marks, but there is a list of them", () => {
const schema = z.interface({
one: z.boolean(),
"two?": z.boolean(),
});
expect(Object.keys(schema._zod.def.shape)).toEqual(["one", "two"]);
expect(schema._zod.def.optional).toEqual(["two"]);
});
});

describe("Vitest error comparison", () => {
test("should distinguish error instances of different classes", () => {
expect(createHttpError(500, "some message")).not.toEqual(
Expand Down
59 changes: 34 additions & 25 deletions express-zod-api/tests/integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,33 +9,42 @@ import {
} from "../src";

describe("Integration", () => {
test("Should support types variant and handle recursive schemas", () => {
const recursiveSchema: z.ZodTypeAny = z.lazy(() =>
z.object({
name: z.string(),
features: recursiveSchema,
}),
);
const recursive1: z.ZodTypeAny = z.lazy(() =>
z.object({
name: z.string(),
features: recursive1,
}),
);
const recursive2 = z.interface({
name: z.string(),
get features() {
return recursive2;
},
});

const client = new Integration({
variant: "types",
routing: {
v1: {
test: defaultEndpointsFactory
.build({
method: "post",
input: z.object({
features: recursiveSchema,
}),
output: z.object({}),
handler: async () => ({}),
})
.deprecated(),
test.each([recursive1, recursive2])(
"Should support types variant and handle recursive schemas %#",
(recursiveSchema) => {
const client = new Integration({
variant: "types",
routing: {
v1: {
test: defaultEndpointsFactory
.build({
method: "post",
input: z.object({
features: recursiveSchema,
}),
output: z.object({}),
handler: async () => ({}),
})
.deprecated(),
},
},
},
});
expect(client.print()).toMatchSnapshot();
});
});
expect(client.print()).toMatchSnapshot();
},
);

test("Should treat optionals the same way as z.infer() by default", async () => {
const client = new Integration({
Expand Down
18 changes: 18 additions & 0 deletions express-zod-api/tests/io-schema.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { expectTypeOf } from "vitest";
import { z } from "zod";
import { IOSchema, Middleware, ez } from "../src";
import {
Expand All @@ -15,6 +16,15 @@ describe("I/O Schema and related helpers", () => {
expectTypeOf(z.object({}).strict()).toExtend<IOSchema>();
expectTypeOf(z.object({}).loose()).toExtend<IOSchema>();
});
test("accepts interface", () => {
expectTypeOf(z.interface({})).toExtend<IOSchema>();
expectTypeOf(z.interface({ "some?": z.string() })).toExtend<IOSchema>();
expectTypeOf(z.interface({}).strip()).toExtend<IOSchema>();
expectTypeOf(z.interface({}).loose()).toExtend<IOSchema>();
expectTypeOf(z.looseInterface({})).toExtend<IOSchema>();
expectTypeOf(z.interface({}).strict()).toExtend<IOSchema>();
expectTypeOf(z.strictInterface({})).toExtend<IOSchema>();
});
test("accepts ez.raw()", () => {
expectTypeOf(ez.raw()).toExtend<IOSchema>();
expectTypeOf(ez.raw({ something: z.any() })).toExtend<IOSchema>();
Expand Down Expand Up @@ -340,6 +350,14 @@ describe("I/O Schema and related helpers", () => {
});

describe("Zod 4", () => {
test("should handle interfaces with optional props", () => {
expect(
extractObjectSchema(
z.interface({ one: z.boolean(), "two?": z.boolean() }),
),
).toMatchSnapshot();
});

test("should throw for incompatible ones", () => {
expect(() =>
extractObjectSchema(z.string() as unknown as IOSchema),
Expand Down
8 changes: 8 additions & 0 deletions express-zod-api/tests/zts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ describe("zod-to-ts", () => {
}),
);

const circular2 = z.interface({
name: z.string(),
get subcategories() {
return z.array(circular2);
},
});

const example = z.object({
string: z.string(),
number: z.number(),
Expand All @@ -109,6 +116,7 @@ describe("zod-to-ts", () => {
),
boolean: z.boolean(),
circular,
circular2,
union: z.union([z.object({ number: z.number() }), z.literal("hi")]),
enum: z.enum(["hi", "bye"]),
intersectionWithTransform: z
Expand Down