Skip to content
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

[<img src="https://github.com/ThomasKientz.png" alt="@ThomasKientz" width="50px" />](https://github.com/ThomasKientz)
[<img src="https://github.com/james10424.png" alt="@james10424" width="50px" />](https://github.com/james10424)
[<img src="https://github.com/HeikoOsigus.png" alt="@HeikoOsigus" width="50px" />](https://github.com/HeikoOsigus)
[<img src="https://github.com/crgeary.png" alt="@crgeary" width="50px" />](https://github.com/crgeary)
Expand Down
20 changes: 14 additions & 6 deletions express-zod-api/src/endpoints-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
> {
Expand All @@ -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.output<z.ZodIntersection<MIN, IN>>, z.input<OUT>, OPT>;
handler: Handler<
z.output<ConditionalIntersection<MIN, IN>>,
z.input<OUT>,
OPT
>;
/** @desc The operation description for the generated Documentation */
description?: string;
/** @desc The operation summary for the generated Documentation (50 symbols max) */
Expand Down Expand Up @@ -59,15 +67,15 @@ interface BuildProps<
}

export class EndpointsFactory<
IN extends IOSchema = EmptySchema,
IN extends IOSchema | undefined = undefined,
OUT extends FlatObject = EmptyObject,
SCO extends string = string,
> {
protected middlewares: AbstractMiddleware[] = [];
constructor(protected resultHandler: AbstractResultHandler) {}

static #create<
CIN extends IOSchema,
CIN extends IOSchema | undefined,
COUT extends FlatObject,
CSCO extends string,
>(middlewares: AbstractMiddleware[], resultHandler: AbstractResultHandler) {
Expand All @@ -86,7 +94,7 @@ export class EndpointsFactory<
| ConstructorParameters<typeof Middleware<OUT, AOUT, ASCO, AIN>>[0],
) {
return EndpointsFactory.#create<
z.ZodIntersection<IN, AIN>,
ConditionalIntersection<IN, AIN>,
OUT & AOUT,
SCO & ASCO
>(
Expand Down
12 changes: 10 additions & 2 deletions express-zod-api/src/io-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Base>;

export type ConditionalIntersection<
Current extends IOSchema | undefined,
Inc extends IOSchema,
> = z.ZodIntersection<Current extends IOSchema ? Current : Inc, Inc>;

/**
* @description intersects input schemas of middlewares and the endpoint
* @since 07.03.2022 former combineEndpointAndMiddlewareInputSchemas()
Expand All @@ -15,12 +20,15 @@ export type IOSchema = z.ZodType<Base>;
* @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[],
input: IN,
) =>
R.pluck("schema", middlewares)
.concat(input)
.reduce((acc, schema) => acc.and(schema)) as z.ZodIntersection<MIN, IN>;
.reduce((acc, schema) => acc.and(schema)) as ConditionalIntersection<
MIN,
IN
>;
4 changes: 2 additions & 2 deletions express-zod-api/src/sse.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -100,7 +100,7 @@ export const makeResultHandler = <E extends EventsMap>(events: E) =>
});

export class EventStreamFactory<E extends EventsMap> extends EndpointsFactory<
EmptySchema,
undefined,
Emitter<E>
> {
constructor(events: E) {
Expand Down
50 changes: 34 additions & 16 deletions express-zod-api/tests/endpoints-factory.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { RequestHandler } from "express";
import createHttpError from "http-errors";
import { expectTypeOf } from "vitest";
import {
EndpointsFactory,
Middleware,
Expand Down Expand Up @@ -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<unknown>();
return input;
},
});
});
});

describe(".addOptions()", () => {
Expand All @@ -92,7 +115,7 @@ describe("EndpointsFactory", () => {
}));
expectTypeOf(newFactory).toEqualTypeOf<
EndpointsFactory<
EmptySchema,
undefined,
EmptyObject & { option1: string; option2: string }
>
>();
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -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 })
>();
});
Expand Down