diff --git a/CHANGELOG.md b/CHANGELOG.md index b86301423..8c8a6d099 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ ## Version 24 +### v24.4.2 + +- Improved the type of the `input` argument for `Endpoint::handler()`: + - For `z.object()`-based schema removed `never` for unknown properties; + - Fixed incorrect typing of excessive properties when using a `z.looseObject()`-based schema; + - The issue reported by [@ThomasKientz](https://github.com/ThomasKientz). + +```ts +import { defaultEndpointsFactory } from "express-zod-api"; +import { z } from "zod/v4"; + +const endpoint1 = defaultEndpointsFactory.buildVoid({ + input: z.object({ + foo: z.string(), + }), + handler: async ({ input: { bar } }) => { + console.log(bar); // before: never, after: TypeScript Error + }, +}); + +const endpoint2 = defaultEndpointsFactory.buildVoid({ + input: z.looseObject({ + foo: z.string(), + }), + handler: async ({ input: { bar } }) => { + console.log(bar); // before: never, after: unknown + }, +}); +``` + ### v24.4.1 - Compatibility fix for Zod 3.25.67. diff --git a/README.md b/README.md index bb6249841..c70f0ab8f 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ Therefore, many basic tasks can be accomplished faster and easier, in particular These people contributed to the improvement of the framework by reporting bugs, making changes and suggesting ideas: +[@ThomasKientz](https://github.com/ThomasKientz) [@james10424](https://github.com/james10424) [@HeikoOsigus](https://github.com/HeikoOsigus) [@crgeary](https://github.com/crgeary) diff --git a/express-zod-api/src/endpoints-factory.ts b/express-zod-api/src/endpoints-factory.ts index a82854adf..d7fa5b957 100644 --- a/express-zod-api/src/endpoints-factory.ts +++ b/express-zod-api/src/endpoints-factory.ts @@ -2,7 +2,11 @@ import { Request, Response } from "express"; import { z } from "zod/v4"; import { EmptyObject, EmptySchema, FlatObject, Tag } from "./common-helpers"; import { Endpoint, Handler } from "./endpoint"; -import { IOSchema, getFinalEndpointInputSchema } from "./io-schema"; +import { + IOSchema, + getFinalEndpointInputSchema, + ConditionalIntersection, +} from "./io-schema"; import { Method } from "./method"; import { AbstractMiddleware, @@ -18,7 +22,7 @@ import { interface BuildProps< IN extends IOSchema, OUT extends IOSchema | z.ZodVoid, - MIN extends IOSchema, + MIN extends IOSchema | undefined, OPT extends FlatObject, SCO extends string, > { @@ -31,7 +35,11 @@ interface BuildProps< /** @desc The schema by which the returns of the Endpoint handler is validated */ output: OUT; /** @desc The Endpoint handler receiving the validated inputs, returns of added Middlewares (options) and a logger */ - handler: Handler>, z.input, OPT>; + handler: Handler< + z.output>, + z.input, + OPT + >; /** @desc The operation description for the generated Documentation */ description?: string; /** @desc The operation summary for the generated Documentation (50 symbols max) */ @@ -59,7 +67,7 @@ interface BuildProps< } export class EndpointsFactory< - IN extends IOSchema = EmptySchema, + IN extends IOSchema | undefined = undefined, OUT extends FlatObject = EmptyObject, SCO extends string = string, > { @@ -67,7 +75,7 @@ export class EndpointsFactory< constructor(protected resultHandler: AbstractResultHandler) {} static #create< - CIN extends IOSchema, + CIN extends IOSchema | undefined, COUT extends FlatObject, CSCO extends string, >(middlewares: AbstractMiddleware[], resultHandler: AbstractResultHandler) { @@ -86,7 +94,7 @@ export class EndpointsFactory< | ConstructorParameters>[0], ) { return EndpointsFactory.#create< - z.ZodIntersection, + ConditionalIntersection, OUT & AOUT, SCO & ASCO >( diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index cabadf807..66a2032d8 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -7,6 +7,11 @@ type Base = object & { [Symbol.iterator]?: never }; /** @desc The type allowed on the top level of Middlewares and Endpoints */ export type IOSchema = z.ZodType; +export type ConditionalIntersection< + Current extends IOSchema | undefined, + Inc extends IOSchema, +> = z.ZodIntersection; + /** * @description intersects input schemas of middlewares and the endpoint * @since 07.03.2022 former combineEndpointAndMiddlewareInputSchemas() @@ -15,7 +20,7 @@ export type IOSchema = z.ZodType; * @since 22.05.2025 does not mix examples in after switching to Zod 4 */ export const getFinalEndpointInputSchema = < - MIN extends IOSchema, + MIN extends IOSchema | undefined, IN extends IOSchema, >( middlewares: AbstractMiddleware[], @@ -23,4 +28,7 @@ export const getFinalEndpointInputSchema = < ) => R.pluck("schema", middlewares) .concat(input) - .reduce((acc, schema) => acc.and(schema)) as z.ZodIntersection; + .reduce((acc, schema) => acc.and(schema)) as ConditionalIntersection< + MIN, + IN + >; diff --git a/express-zod-api/src/sse.ts b/express-zod-api/src/sse.ts index 587967fe3..0f9692031 100644 --- a/express-zod-api/src/sse.ts +++ b/express-zod-api/src/sse.ts @@ -1,6 +1,6 @@ import { Response } from "express"; import { z } from "zod/v4"; -import { EmptySchema, FlatObject } from "./common-helpers"; +import { FlatObject } from "./common-helpers"; import { contentTypes } from "./content-type"; import { EndpointsFactory } from "./endpoints-factory"; import { Middleware } from "./middleware"; @@ -100,7 +100,7 @@ export const makeResultHandler = (events: E) => }); export class EventStreamFactory extends EndpointsFactory< - EmptySchema, + undefined, Emitter > { constructor(events: E) { diff --git a/express-zod-api/tests/endpoints-factory.spec.ts b/express-zod-api/tests/endpoints-factory.spec.ts index 8780588b7..b04ac3bce 100644 --- a/express-zod-api/tests/endpoints-factory.spec.ts +++ b/express-zod-api/tests/endpoints-factory.spec.ts @@ -1,5 +1,6 @@ import { RequestHandler } from "express"; import createHttpError from "http-errors"; +import { expectTypeOf } from "vitest"; import { EndpointsFactory, Middleware, @@ -81,6 +82,28 @@ describe("EndpointsFactory", () => { > >(); }); + + test("Issue #2760: should strip excessive props by default", () => { + defaultEndpointsFactory.build({ + input: z.object({ foo: z.string() }), + output: z.object({ foo: z.string() }), + handler: async ({ input }) => { + expectTypeOf(input).not.toHaveProperty("bar"); + return input; + }, + }); + }); + + test("Issue #2760: should allow excessive props when using loose object schema", () => { + defaultEndpointsFactory.build({ + input: z.looseObject({ foo: z.string() }), + output: z.object({ foo: z.string() }), + handler: async ({ input }) => { + expectTypeOf(input).toHaveProperty("bar").toEqualTypeOf(); + return input; + }, + }); + }); }); describe(".addOptions()", () => { @@ -92,7 +115,7 @@ describe("EndpointsFactory", () => { })); expectTypeOf(newFactory).toEqualTypeOf< EndpointsFactory< - EmptySchema, + undefined, EmptyObject & { option1: string; option2: string } > >(); @@ -249,10 +272,9 @@ describe("EndpointsFactory", () => { expect(endpoint.methods).toBeUndefined(); expect(endpoint.inputSchema).toMatchSnapshot(); expect(endpoint.outputSchema).toMatchSnapshot(); - expectTypeOf(endpoint.inputSchema._zod.output).toExtend<{ - n: number; - s: string; - }>(); + expectTypeOf(endpoint.inputSchema._zod.output).toEqualTypeOf< + { n: number } & { s: string } + >(); }); test("Should create an endpoint with refined object middleware", () => { @@ -277,11 +299,9 @@ describe("EndpointsFactory", () => { }); expect(endpoint.inputSchema).toMatchSnapshot(); expect(endpoint.outputSchema).toMatchSnapshot(); - expectTypeOf(endpoint.inputSchema._zod.output).toExtend<{ - a?: number; - b?: string; - i: string; - }>(); + expectTypeOf(endpoint.inputSchema._zod.output).toEqualTypeOf< + { a?: number; b?: string } & { i: string } + >(); }); test("Should create an endpoint with intersection middleware", () => { @@ -302,11 +322,9 @@ describe("EndpointsFactory", () => { expect(endpoint.methods).toBeUndefined(); expect(endpoint.inputSchema).toMatchSnapshot(); expect(endpoint.outputSchema).toMatchSnapshot(); - expectTypeOf(endpoint.inputSchema._zod.output).toExtend<{ - n1: number; - n2: number; - s: string; - }>(); + expectTypeOf(endpoint.inputSchema._zod.output).toEqualTypeOf< + { n1: number } & { n2: number } & { s: string } + >(); }); test("Should create an endpoint with union middleware", () => { @@ -330,7 +348,7 @@ describe("EndpointsFactory", () => { expect(endpoint.methods).toBeUndefined(); expect(endpoint.inputSchema).toMatchSnapshot(); expect(endpoint.outputSchema).toMatchSnapshot(); - expectTypeOf(endpoint.inputSchema._zod.output).toExtend< + expectTypeOf(endpoint.inputSchema._zod.output).toEqualTypeOf< { s: string } & ({ n1: number } | { n2: number }) >(); });