Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
b8db8a7
br(deps): Making zod plugin an optional peer dependency.
RobinTail Apr 10, 2026
f3175e9
upd lockfile.
RobinTail Apr 10, 2026
38b28c2
add plugin to dev deps as well.
RobinTail Apr 10, 2026
2b50d27
rm importing the plugin in vitest setup.
RobinTail Apr 10, 2026
11e224f
fix(ez): prevent brand override.
RobinTail Apr 10, 2026
848208d
fix(deep): shorter syntax.
RobinTail Apr 10, 2026
451672b
fix(raw): simpler type.
RobinTail Apr 10, 2026
3fd3515
fix(test): better assertion.
RobinTail Apr 10, 2026
0826316
BR(plugin): Moving getBrand() to the framework.
RobinTail Apr 11, 2026
1a0bdb1
fix(test): using getBrand again in deep checks test.
RobinTail Apr 11, 2026
100b768
fix(test): reducing diff.
RobinTail Apr 11, 2026
7dba992
Merge branch 'make-v28' into opt-zod-plugin
RobinTail Apr 11, 2026
eb8e12f
fix(example): mv plugin import into routing.
RobinTail Apr 11, 2026
11c287c
README: reflecting the breaking change (draft, AI).
RobinTail Apr 11, 2026
67d6467
Merge branch 'make-v28' into opt-zod-plugin
RobinTail Apr 11, 2026
612689b
fix(docs): Plugin readme, changing 'from' to 'for'.
RobinTail Apr 11, 2026
9ba1822
Changelog: reflecting on v28.
RobinTail Apr 11, 2026
320c2aa
Readme: polishing zod plugin section.
RobinTail Apr 11, 2026
ef69524
fix(docs): Adjusting documentation section.
RobinTail Apr 11, 2026
3434846
fix(docs): Adjusting the deprecation article.
RobinTail Apr 11, 2026
c00dfa2
fix(docs): Improving the brands handling article.
RobinTail Apr 11, 2026
93f06f3
fix(docs): typo
RobinTail Apr 11, 2026
2cfd4c9
fix(test): better assertion for no exports from the plugin.
RobinTail Apr 11, 2026
f68afc1
feat: getExamples() helper and general metadata file.
RobinTail Apr 11, 2026
a2566c6
ref: using getExamples by pullResponseExamples.
RobinTail Apr 11, 2026
7d842e0
ref: using getExamples by defaultResultHandler.
RobinTail Apr 11, 2026
17ac7e5
fix(import): consistent import of the new file.
RobinTail Apr 11, 2026
e3ea8e3
fix(test): rm plugin import from integration test.
RobinTail Apr 11, 2026
7c51d96
fix(test): rm plugin import in documentation test.
RobinTail Apr 11, 2026
924be39
fix(deps): rm the plugin from the framework devDependencies.
RobinTail Apr 11, 2026
2f707a0
Revert "fix(deps): rm the plugin from the framework devDependencies."
RobinTail Apr 11, 2026
88232a8
fix(docs): Add import statement for the plugin demo.
RobinTail Apr 11, 2026
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
### v28.0.0

- Supported Node.js versions: `^22.19.0 || ^24.0.0`;
- The Zod plugin is no longer installed automatically — it's an optional peer dependency now:
- To keep using `.example()`, `.label()`, `.remap()`, `.deprecated()` and `.brand()` methods on schemas
install the `@express-zod-api/zod-plugin` manually and import it (ideally at the top of a file declaring `Routing`);
- Breaking changes to the `createConfig()` argument (object):
- property `wrongMethodBehavior` (number) changed to `hintAllowedMethods` (boolean);
- property `methodLikeRouteBehavior` (string literal) changed to `recognizeMethodDependentRoutes` (boolean);
Expand Down
41 changes: 28 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ Much can be customized to fit your needs.

- [Typescript](https://www.typescriptlang.org/) first.
- Web server — [Express.js](https://expressjs.com/) v5.
- Schema validation — [Zod 4.x](https://github.com/colinhacks/zod) including [Zod Plugin](#zod-plugin):
- Schema validation — [Zod 4.x](https://github.com/colinhacks/zod)
- For using with Zod 3.x, install the framework versions below 24.0.0.
- Supports any logger having `info()`, `debug()`, `error()` and `warn()` methods;
- Built-in console logger with colorful and pretty inspections by default.
Expand Down Expand Up @@ -1100,8 +1100,21 @@ expect(output).toEqual({ collectedContext: ["prev"], testLength: 9 });

## Zod Plugin

Express Zod API augments Zod using [Zod Plugin](https://www.npmjs.com/package/@express-zod-api/zod-plugin),
adding the runtime helpers the framework relies on.
The [@express-zod-api/zod-plugin](https://www.npmjs.com/package/@express-zod-api/zod-plugin) is an optional package
that extends Zod with convenience methods:

- `.brand(name)` — enhanced with a shorthand for `.meta({ "x-brand": name })`;
- `.example(value)` — shorthand for `.meta({ examples: [value] })`;
- `.deprecated()` — shorthand for `.meta({ deprecated: true })`;
- `.label(text)` — shorthand for `.meta({ default: text })` on `ZodDefault`;
- `.remap(mapping)` — for renaming `ZodObject` shape properties;

To benefit from these methods, install `@express-zod-api/zod-plugin` and import it once, preferably at the top of a
file declaring your `Routing`.

```ts
import "@express-zod-api/zod-plugin"; // in your routing.ts file
```

## End-to-End Type Safety

Expand Down Expand Up @@ -1166,16 +1179,17 @@ const exampleEndpoint = defaultEndpointsFactory.build({
description: "The detailed explanaition on what this endpoint does.",
input: z.object({
id: z
.string()
.example("123") // input examples should be set before transformations
.string() // input examples should be set before transformations
.example("123") // requires Zod Plugin, or .meta({ examples: ["123"] })
.transform(Number)
.describe("the ID of the user"),
}),
// ..., similarly for output and middlewares
});
```

You can also use `schema.meta({ id: "UniqueName" })` for custom schema naming.
Setting examples via `.example()` requires [Zod Plugin](#zod-plugin). You can also use `.meta({ examples: [] })` and
`.meta({ id: "UniqueName" })` for custom schema naming.
_See the complete example of the generated documentation
[here](https://github.com/RobinTail/express-zod-api/blob/master/example/example.documentation.yaml)_

Expand Down Expand Up @@ -1213,9 +1227,9 @@ new Documentation({

## Deprecated schemas and routes

As your API evolves, you may need to mark some parameters or routes as deprecated before deleting them. For this
purpose, the `.deprecated()` method is available on each schema and `Endpoint`, it's immutable.
You can also deprecate all routes the `Endpoint` assigned to by setting `EndpointsFactory::build({ deprecated: true })`.
As your API evolves, you may need to mark some parameters or routes as deprecated before deleting them. This can be
achieved using the corresponding method or metadata. The `.deprecated()` method on Zod schema requires to install the
[Zod Plugin](#zod-plugin). Consider the following example:

```ts
import type { Routing } from "express-zod-api";
Expand All @@ -1224,7 +1238,7 @@ import { z } from "zod";
const someEndpoint = factory.build({
deprecated: true, // deprecates all routes the endpoint assigned to
input: z.object({
prop: z.string().deprecated(), // deprecates the property or a path parameter
prop: z.string().deprecated(), // requires Zod Plugin, or .meta({ deprecated: true })
}),
});

Expand All @@ -1236,8 +1250,9 @@ const routing: Routing = {

## Customizable brands handling

You can customize handling rules for your schemas in Documentation and Integration. Use the `.brand()` method on your
schema to make it special and distinguishable for the framework in runtime. Using symbols is recommended for branding.
You can customize handling rules for your schemas in Documentation and Integration. The framework treats your schema
specially based on its `x-brand` metadata. When the [Zod Plugin](#zod-plugin) is installed you can conveniently use
the `.brand()` enhanced method of the Zod schema, preferably with a symbol argument for its branding.
After that use the `brandHandling` feature of both constructors to declare your custom implementation. In case you need
to reuse a handling rule for multiple brands, use the exposed types `Depicter` and `Producer`.

Expand All @@ -1252,7 +1267,7 @@ import {
} from "express-zod-api";

const myBrand = Symbol("MamaToldMeImSpecial"); // I recommend to use symbols for this purpose
const myBrandedSchema = z.string().brand(myBrand);
const myBrandedSchema = z.string().brand(myBrand); // requires Zod Plugin, or .meta({ "x-brand": myBrand })

const ruleForDocs: Depicter = (
{ zodSchema, jsonSchema }, // jsonSchema is the default depiction
Expand Down
2 changes: 1 addition & 1 deletion cjs-test/zod-plugin.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);

require("express-zod-api"); // side effect here via Zod Plugin
require("@express-zod-api/zod-plugin"); // side effect here
const z = require("zod"); // ensure CJS version of Zod is used

describe("Zod plugin in CJS environment", () => {
Expand Down
8 changes: 0 additions & 8 deletions compat-test/dts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,6 @@ import { describe, test, expect } from "vitest";
import { readFile } from "node:fs/promises";

describe("DTS", () => {
test("Framework must import Zod plugin", async () => {
const fwDts = await readFile(
"./node_modules/express-zod-api/dist/index.d.ts",
"utf-8",
);
expect(fwDts).toMatch(`import "@express-zod-api/zod-plugin";`);
});

test("Zod plugin must import augmentation", async () => {
const pluginDts = await readFile(
"./node_modules/express-zod-api/node_modules/@express-zod-api/zod-plugin/dist/index.d.ts",
Expand Down
1 change: 1 addition & 0 deletions example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"test": "vitest run index.spec.ts"
},
"devDependencies": {
"@express-zod-api/zod-plugin": "workspace:*",
"@types/http-errors": "catalog:dev",
"@types/swagger-ui-express": "^4.1.8",
"express-zod-api": "workspace:*",
Expand Down
1 change: 1 addition & 0 deletions example/routing.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import "@express-zod-api/zod-plugin"; // adds .example() method
import { type Routing, ServeStatic } from "express-zod-api";
import { rawAcceptingEndpoint } from "./endpoints/accept-raw.ts";
import { createUserEndpoint } from "./endpoints/create-user.ts";
Expand Down
7 changes: 5 additions & 2 deletions express-zod-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
"prepack": "cp ../*.md ../LICENSE ./"
},
"type": "module",
"sideEffects": true,
"main": "dist/index.js",
"types": "dist/index.d.ts",
"module": "dist/index.js",
Expand All @@ -42,13 +41,13 @@
"node": "^22.19.0 || ^24.0.0"
},
"dependencies": {
"@express-zod-api/zod-plugin": "workspace:^",
"ansis": "^4.2.0",
"node-mocks-http": "^1.17.2",
"openapi3-ts": "^4.5.0",
"ramda": "catalog:prod"
},
"peerDependencies": {
"@express-zod-api/zod-plugin": "workspace:^",
"@types/compression": "^1.7.5",
"@types/express": "^5.0.0",
"@types/express-fileupload": "^1.5.0",
Expand All @@ -61,6 +60,9 @@
"zod": "catalog:peer"
},
"peerDependenciesMeta": {
"@express-zod-api/zod-plugin": {
"optional": true
},
"@types/compression": {
"optional": true
},
Expand All @@ -84,6 +86,7 @@
}
},
"devDependencies": {
"@express-zod-api/zod-plugin": "workspace:^",
"@types/compression": "catalog:dev",
"@types/cors": "^2.8.19",
"@types/depd": "^1.1.37",
Expand Down
3 changes: 2 additions & 1 deletion express-zod-api/src/buffer-schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from "zod";
import { brandProperty } from "./metadata";

export const ezBufferBrand = Symbol("Buffer");

Expand All @@ -7,4 +8,4 @@ export const buffer = () =>
.custom<Buffer>((subject) => Buffer.isBuffer(subject), {
error: "Expected Buffer",
})
.brand(ezBufferBrand as symbol);
.meta({ [brandProperty]: ezBufferBrand });
4 changes: 2 additions & 2 deletions express-zod-api/src/date-in-schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from "zod";
import { brandProperty } from "./metadata";

export const ezDateInBrand = Symbol("DateIn");

Expand All @@ -20,6 +21,5 @@ export const dateIn = ({ examples, ...rest }: DateInParams = {}) => {
.meta({ examples })
.transform((str) => new Date(str))
.pipe(z.date())
.brand(ezDateInBrand as symbol)
.meta(rest);
.meta({ ...rest, [brandProperty]: ezDateInBrand });
};
4 changes: 2 additions & 2 deletions express-zod-api/src/date-out-schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from "zod";
import { brandProperty } from "./metadata";

export const ezDateOutBrand = Symbol("DateOut");

Expand All @@ -13,5 +14,4 @@ export const dateOut = (meta: DateOutParams = {}) =>
z
.date()
.transform((date) => date.toISOString())
.brand(ezDateOutBrand as symbol)
.meta(meta);
.meta({ ...meta, [brandProperty]: ezDateOutBrand });
2 changes: 1 addition & 1 deletion express-zod-api/src/deep-checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ezDateOutBrand } from "./date-out-schema";
import { DeepCheckError } from "./errors";
import { ezFormBrand } from "./form-schema";
import type { IOSchema } from "./io-schema";
import { getBrand } from "@express-zod-api/zod-plugin";
import { getBrand } from "./metadata";
import type { FirstPartyKind } from "./schema-walker";
import { ezUploadBrand } from "./upload-schema";
import { ezRawBrand } from "./raw-schema";
Expand Down
2 changes: 1 addition & 1 deletion express-zod-api/src/documentation-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import { DocumentationError } from "./errors";
import type { IOSchema } from "./io-schema";
import { flattenIO } from "./json-schema-helpers";
import type { Alternatives } from "./logical-container";
import { getBrand } from "@express-zod-api/zod-plugin";
import { getBrand } from "./metadata";
import type { ClientMethod } from "./method";
import type { ProprietaryBrand } from "./proprietary-schemas";
import { ezRawBrand } from "./raw-schema";
Expand Down
6 changes: 3 additions & 3 deletions express-zod-api/src/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import type { IOSchema } from "./io-schema";
import { lastResortHandler } from "./last-resort";
import type { ActualLogger } from "./logger-helpers";
import type { LogicalContainer } from "./logical-container";
import { getBrand } from "@express-zod-api/zod-plugin";
import { getBrand, getExamples } from "./metadata";
import type { ClientMethod, CORSMethod, Method, SomeMethod } from "./method";
import { AbstractMiddleware, ExpressMiddleware } from "./middleware";
import type { ContentType } from "./content-type";
Expand Down Expand Up @@ -92,9 +92,9 @@ export class Endpoint<
> extends AbstractEndpoint {
readonly #def: ConstructorParameters<typeof Endpoint<IN, OUT, CTX>>[0];

/** considered expensive operation, only required for generators */
/** considered an expensive operation, only required for generators */
#ensureOutputExamples = R.once(() => {
if (globalRegistry.get(this.#def.outputSchema)?.examples?.length) return; // examples on output schema, or pull up:
if (getExamples(this.#def.outputSchema).length) return; // examples on output schema, or pull up:
if (!isSchema<z.core.$ZodObject>(this.#def.outputSchema, "object")) return;
const examples = pullResponseExamples(this.#def.outputSchema);
if (!examples.length) return;
Expand Down
7 changes: 4 additions & 3 deletions express-zod-api/src/form-schema.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { z } from "zod";
import { brandProperty } from "./metadata";

export const ezFormBrand = Symbol("Form");

/** @desc Accepts an object shape or a custom object schema */
export const form = <S extends z.core.$ZodShape>(base: S | z.ZodObject<S>) =>
(base instanceof z.ZodObject ? base : z.object(base)).brand(
ezFormBrand as symbol,
);
(base instanceof z.ZodObject ? base : z.object(base)).meta({
[brandProperty]: ezFormBrand,
});
1 change: 0 additions & 1 deletion express-zod-api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import "@express-zod-api/zod-plugin"; // side effects here
export { createConfig } from "./config-type";
export {
EndpointsFactory,
Expand Down
22 changes: 22 additions & 0 deletions express-zod-api/src/metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { brandProperty as brandProp } from "../../zod-plugin/src/brand.ts";
import { globalRegistry, type z } from "zod";

export const brandProperty = "x-brand" satisfies typeof brandProp;

export const getBrand = (subject: z.core.$ZodType) => {
const { [brandProperty]: brand } = globalRegistry.get(subject) || {};
if (
typeof brand === "symbol" ||
typeof brand === "string" ||
typeof brand === "number"
)
return brand;
return undefined;
};

/** @desc Returns examples from the schema metadata always as an array */
export const getExamples = (subject: z.core.$ZodType): unknown[] => {
const { examples } = globalRegistry.get(subject) || {};
if (Array.isArray(examples)) return examples;
return [];
};
7 changes: 4 additions & 3 deletions express-zod-api/src/raw-schema.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
import { z } from "zod";
import { buffer } from "./buffer-schema";
import { brandProperty } from "./metadata";

export const ezRawBrand = Symbol("Raw");

const base = z.object({ raw: buffer() });
type Base = ReturnType<typeof base.brand<symbol>>;
type Base = typeof base;

const extended = <S extends z.core.$ZodShape>(extra: S) =>
base.extend(extra).brand(ezRawBrand as symbol);
base.extend(extra).meta({ [brandProperty]: ezRawBrand });

export function raw(): Base;
export function raw<S extends z.core.$ZodShape>(
extra: S,
): ReturnType<typeof extended<S>>;
export function raw(extra?: z.core.$ZodShape) {
return extra ? extended(extra) : base.brand(ezRawBrand as symbol);
return extra ? extended(extra) : base.meta({ [brandProperty]: ezRawBrand });
}

export type RawSchema = Base;
13 changes: 6 additions & 7 deletions express-zod-api/src/result-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
logServerError,
normalize,
} from "./result-helpers";
import { getExamples } from "./metadata";

type Handler<RES = unknown> = (
params: DiscriminatedResult & {
Expand Down Expand Up @@ -110,8 +111,8 @@ export const defaultResultHandler = new ResultHandler({
status: z.literal("success"),
data: output,
});
const { examples } = globalRegistry.get(output) || {}; // pulling down:
if (examples?.length) {
const examples = getExamples(output); // pulling down:
if (examples.length) {
globalRegistry.add(responseSchema, {
examples: examples.map((data) => ({
status: "success" as const,
Expand Down Expand Up @@ -160,11 +161,9 @@ export const arrayResultHandler = new ResultHandler({
output.shape.items instanceof z.ZodArray
? output.shape.items
: z.array(z.any());
if (globalRegistry.get(responseSchema)?.examples?.length)
return responseSchema; // has examples on the items, or pull down:
const examples = globalRegistry
.get(output)
?.examples?.filter(
if (getExamples(responseSchema).length) return responseSchema; // has examples on the items, or pull down:
const examples = getExamples(output)
.filter(
(example): example is { items: unknown[] } =>
isObject(example) &&
"items" in example &&
Expand Down
16 changes: 8 additions & 8 deletions express-zod-api/src/result-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Request } from "express";
import createHttpError, { HttpError, isHttpError } from "http-errors";
import * as R from "ramda";
import { globalRegistry, z } from "zod";
import { z } from "zod";
import type { NormalizedResponse, ResponseVariant } from "./api-response";
import {
combinations,
Expand All @@ -12,6 +12,7 @@ import {
import { InputValidationError, ResultHandlerError } from "./errors";
import type { ActualLogger } from "./logger-helpers";
import type { LazyResult, Result } from "./result-handler";
import { getExamples } from "./metadata";

export type ResultSchema<R extends Result> =
R extends Result<infer S> ? S : never;
Expand Down Expand Up @@ -90,12 +91,11 @@ export const getPublicErrorMessage = (error: HttpError): string =>
/** @see pullRequestExamples */
export const pullResponseExamples = <T extends z.core.$ZodObject>(subject: T) =>
Object.entries(subject._zod.def.shape).reduce<FlatObject[]>(
(acc, [key, schema]) => {
const { examples = [] } = globalRegistry.get(schema) || {};
return combinations(acc, examples.map(R.objOf(key)), ([left, right]) => ({
...left,
...right,
}));
},
(acc, [key, schema]) =>
combinations(
acc,
getExamples(schema).map(R.objOf(key)),
([left, right]) => ({ ...left, ...right }),
),
[],
);
Loading
Loading