diff --git a/express-zod-api/tests/env.spec.ts b/express-zod-api/tests/env.spec.ts index 306d4eed77..49f7c923d3 100644 --- a/express-zod-api/tests/env.spec.ts +++ b/express-zod-api/tests/env.spec.ts @@ -129,6 +129,12 @@ describe("Environment checks", () => { expect(schema.meta()).toMatchSnapshot(); }); + test("metadata is inheritable since zod 4.3.0", () => { + const parent = z.string().meta({ one: "test" }); + const subject = parent.min(1).meta({ two: "another" }); + expect(subject.meta()).toHaveProperty("one", "test"); + }); + test("object shape conveys the keys optionality", () => { const schema = z.object({ one: z.boolean(), diff --git a/express-zod-api/tests/integration.spec.ts b/express-zod-api/tests/integration.spec.ts index 76653c35ac..9edc16d10d 100644 --- a/express-zod-api/tests/integration.spec.ts +++ b/express-zod-api/tests/integration.spec.ts @@ -1,5 +1,5 @@ import ts from "typescript"; -import { z } from "zod"; +import { globalRegistry, z } from "zod"; import { EndpointsFactory, Integration, @@ -133,7 +133,7 @@ describe("Integration", () => { schema: ReturnType, { next }, ) => { - schema._zod.bag.brand = undefined; + globalRegistry.remove(schema); return next(schema); }; const client = await Integration.create({ diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bd833e0dad..ede91225a2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -50,7 +50,7 @@ catalogs: prod: "ramda": "^0.32.0" peer: - "zod": "^4.1.13" + "zod": "^4.3.4" dev: "@types/compression": "^1.8.1" "@types/express": "^5.0.6" diff --git a/zod-plugin/CHANGELOG.md b/zod-plugin/CHANGELOG.md index ab6acfa264..63ca41a0da 100644 --- a/zod-plugin/CHANGELOG.md +++ b/zod-plugin/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Version 4 + +### v4.0.0 + +- Supported `zod` versions: `^4.3.4`; +- Zod made metadata inheritable, so that `pack()` and `unpack()` are removed; + - Use the `.meta()` method of a schema instead of both helpers; +- Runtime distinguishable brand is now stored along with other metadata. + ## Version 3 ### v3.0.0 diff --git a/zod-plugin/README.md b/zod-plugin/README.md index 53b407b359..71410198c1 100644 --- a/zod-plugin/README.md +++ b/zod-plugin/README.md @@ -13,12 +13,12 @@ This module extends Zod functionality when it's imported: - Adds `.remap()` method to `ZodObject` for renaming object properties: - Supports a mapping object or an object transforming function as an argument; - Relies on `R.renameKeys()` from the `ramda` library; -- Alters the `.brand()` method on all Zod schemas by making the assigned brand available in runtime: - - The brand set this way will withstand refinements, unlike the metadata set by `.meta()`. +- Alters the `.brand()` method on all Zod schemas: + - shorthand for `.meta({ "x-brand": ... })` making the brand available in runtime via `getBrand()` helper; ## Requirements -- Zod `^4.1.13` +- Zod `^4.3.4` ## Basic usage @@ -29,11 +29,9 @@ import { getBrand } from "@express-zod-api/zod-plugin"; const schema = z.string().example("test").example("another").brand("custom"); getBrand(schema); // "custom" -schema.meta(); // { examples: ["test", "another"] } +schema.meta(); // { examples: ["test", "another"], "x-brand": "custom" } ``` ## Helpers -- `getBrand()` — retrieves the brand from the schema that was set by its `.brand()` method; -- `pack()` — returns a cloned schema having inheritable attributes assigned (such as brand); -- `unpack()` — retrieves the attributes from the schema that was set by `pack()` helper. +- `getBrand()` — retrieves the brand from the schema that was set by its `.brand()` method. diff --git a/zod-plugin/package.json b/zod-plugin/package.json index bc4094aadb..6758d29255 100644 --- a/zod-plugin/package.json +++ b/zod-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@express-zod-api/zod-plugin", - "version": "3.0.0", + "version": "4.0.0-beta.1", "license": "MIT", "description": "Zod plugin for express-zod-api", "sideEffects": true, diff --git a/zod-plugin/src/brand.ts b/zod-plugin/src/brand.ts index ba72d325f4..c0a7ba0788 100644 --- a/zod-plugin/src/brand.ts +++ b/zod-plugin/src/brand.ts @@ -1,20 +1,14 @@ -import { z } from "zod"; -import { pack, unpack } from "./packer"; +import { globalRegistry, z } from "zod"; -/** The property within schema._zod.bag where we store the brand */ -export const brandProperty = "brand" as const; - -/** Used by runtime (bound) */ -export const setBrand = function (this: z.ZodType, brand?: PropertyKey) { - return pack(this, { [brandProperty]: brand }); -}; +/** The property we store the brand in */ +export const brandProperty = "x-brand" as const; /** * @public * @desc Retrieves the brand from the schema set by its .brand() method. * */ export const getBrand = (subject: z.core.$ZodType) => { - const { [brandProperty]: brand } = unpack(subject) || {}; + const { [brandProperty]: brand } = globalRegistry.get(subject) || {}; if ( typeof brand === "symbol" || typeof brand === "string" || diff --git a/zod-plugin/src/index.ts b/zod-plugin/src/index.ts index 057940f970..fd7b1155f3 100644 --- a/zod-plugin/src/index.ts +++ b/zod-plugin/src/index.ts @@ -1,3 +1,2 @@ import "./runtime"; // side effects here -export { pack, unpack } from "./packer"; export { getBrand } from "./brand"; diff --git a/zod-plugin/src/meta.ts b/zod-plugin/src/meta.ts index cea3679118..652fc3ddb4 100644 --- a/zod-plugin/src/meta.ts +++ b/zod-plugin/src/meta.ts @@ -1,4 +1,5 @@ import { globalRegistry, type z } from "zod"; +import { brandProperty } from "./brand"; export const exampleSetter = function ( this: z.ZodType, @@ -16,3 +17,7 @@ export const deprecationSetter = function (this: z.ZodType) { export const labelSetter = function (this: z.ZodDefault, defaultLabel: string) { return this.meta({ default: defaultLabel }); }; + +export const brandSetter = function (this: z.ZodType, brand?: PropertyKey) { + return this.meta({ [brandProperty]: brand }); +}; diff --git a/zod-plugin/src/packer.ts b/zod-plugin/src/packer.ts deleted file mode 100644 index 8b1963a562..0000000000 --- a/zod-plugin/src/packer.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { z } from "zod"; - -interface $PackerDef extends z.core.$ZodCheckDef { - check: "$Packer"; - bag: B; -} - -interface $PackerInternals extends z.core - .$ZodCheckInternals { - def: $PackerDef; -} - -interface $Packer extends z.core.$ZodCheck { - _zod: $PackerInternals; -} - -/** - * @public - * @desc Attaches inheritable attributes to the schema (withstand refinements). - */ -export const pack = ( - subject: T, - bag: B, -) => { - const Cls = z.core.$constructor<$Packer>("$Packer", (inst, def) => { - z.core.$ZodCheck.init(inst, def); - inst._zod.onattach.push((schema) => { - Object.assign(schema._zod.bag, def.bag); - }); - inst._zod.check = () => {}; - }); - return subject.check(new Cls({ check: "$Packer", bag })) as T & { - _zod: { bag: T["_zod"]["bag"] & B }; - }; -}; - -/** - * @public - * @desc Retrieves the attributes attached to the schema by pack() method. - */ -export const unpack = ( - subject: T, -): T["_zod"]["bag"] => subject._zod.bag; diff --git a/zod-plugin/src/runtime.ts b/zod-plugin/src/runtime.ts index 3b85a68a18..1b9552d901 100644 --- a/zod-plugin/src/runtime.ts +++ b/zod-plugin/src/runtime.ts @@ -1,7 +1,11 @@ import type { z } from "zod"; -import { setBrand } from "./brand"; import { remap } from "./remap"; -import { deprecationSetter, exampleSetter, labelSetter } from "./meta"; +import { + deprecationSetter, + exampleSetter, + labelSetter, + brandSetter, +} from "./meta"; import { getClasses, getPackages } from "./packages"; // eslint-disable-next-line no-restricted-syntax -- substituted by TSDOWN @@ -23,7 +27,7 @@ if (!(pluginFlag in globalThis)) { ["brand" satisfies keyof z.ZodType]: { set() {}, // this is required to override the existing method get() { - return setBrand.bind(this) as z.ZodType["brand"]; + return brandSetter.bind(this) as z.ZodType["brand"]; }, }, }); diff --git a/zod-plugin/tests/brand.spec.ts b/zod-plugin/tests/brand.spec.ts index 362a97bf58..1def208cde 100644 --- a/zod-plugin/tests/brand.spec.ts +++ b/zod-plugin/tests/brand.spec.ts @@ -1,34 +1,20 @@ -import { z } from "zod"; -import { brandProperty, getBrand, setBrand } from "../src/brand"; -import * as packer from "../src/packer"; +import { z, globalRegistry } from "zod"; +import { brandProperty, getBrand } from "../src/brand"; describe("Brand", () => { describe("brandProperty", () => { test("should be brand", () => { - expect(brandProperty).toBe("brand"); - }); - }); - - describe("setBrand", () => { - const packMock = vi.spyOn(packer, "pack"); - - afterAll(() => { - packMock.mockRestore(); - }); - - test("calls pack() with given brand", () => { - const schema = z.string(); - setBrand.call(schema, "test"); - expect(packMock).toHaveBeenCalledWith(schema, { brand: "test" }); + expect(brandProperty).toBe("x-brand"); }); }); describe("getBrand", () => { - test.each([{ brand: "test" }, {}, undefined])( - "should take it from bag", - (bag) => { - const mock = { _zod: { bag } }; - expect(getBrand(mock as unknown as z.core.$ZodType)).toBe(bag?.brand); + test.each([{ [brandProperty]: "test" }, {}, undefined])( + "should take it from metadata in globalRegistry %#", + (metadata) => { + const subject = z.string(); + if (metadata) globalRegistry.add(subject, metadata); + expect(getBrand(subject)).toBe(metadata?.[brandProperty]); }, ); }); diff --git a/zod-plugin/tests/index.spec.ts b/zod-plugin/tests/index.spec.ts index 90bb50e9a4..50f6cee965 100644 --- a/zod-plugin/tests/index.spec.ts +++ b/zod-plugin/tests/index.spec.ts @@ -18,8 +18,6 @@ describe("Entrypoint", () => { test("Exports", () => { expect(entrypoint).toMatchObject({ getBrand: expect.any(Function), - pack: expect.any(Function), - unpack: expect.any(Function), }); }); }); diff --git a/zod-plugin/tests/packer.spec.ts b/zod-plugin/tests/packer.spec.ts deleted file mode 100644 index 0fce7ebcaf..0000000000 --- a/zod-plugin/tests/packer.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { z } from "zod"; -import { pack, unpack } from "../src"; - -describe("Packer", () => { - describe("pack()", () => { - test("should add the bag to the schema", () => { - const schema = pack(z.string(), { one: "test", two: 123 }); - expect(schema._zod.bag).toEqual({ one: "test", two: 123 }); - expectTypeOf(schema._zod.bag).toExtend<{ one: string; two: number }>(); - }); - - test("respects other bag properties", () => { - const schema = pack(z.string().min(2), { one: "test", two: 123 }).max(5); - expect(schema._zod.bag).toEqual({ - one: "test", - two: 123, - minimum: 2, - maximum: 5, - }); - }); - - test("called multiples times merges the bag with last write priority", () => { - const s1 = pack(z.string(), { a: 1, b: 1 }); - const s2 = pack(s1, { b: 2, c: 3 }); - expect(s2._zod.bag).toMatchObject({ a: 1, b: 2, c: 3 }); - }); - - test("does not change parse() method behavior", () => { - const base = z.string().min(2); - const packed = pack(base, { tag: "x" }); - expect(() => packed.parse("x")).toThrow(); // still fails min(2) - expect(packed.parse("ok")).toBe("ok"); - }); - }); - - describe("unpack()", () => { - test("should return the bag from the schema", () => { - const subject = pack(z.string(), { one: "test", two: 123 }); - expect(unpack(subject)).toMatchObject({ one: "test", two: 123 }); - expectTypeOf(unpack(subject)).toExtend<{ one: string; two: number }>(); - }); - }); -}); diff --git a/zod-plugin/tests/runtime.spec.ts b/zod-plugin/tests/runtime.spec.ts index 9678699e35..b14e706950 100644 --- a/zod-plugin/tests/runtime.spec.ts +++ b/zod-plugin/tests/runtime.spec.ts @@ -1,7 +1,6 @@ import { createRequire } from "node:module"; import camelize from "camelize-ts"; import { z as zESM } from "zod"; -import { getBrand } from "../src"; const { z: zCJS } = createRequire(import.meta.url)("zod"); @@ -70,19 +69,25 @@ describe.each<{ variant: string; z: typeof zESM }>([ describe(".brand()", () => { test("should set the brand", () => { - expect(getBrand(z.string().brand("test"))).toBe("test"); + const subject = z.string().brand("test"); + expect(subject.meta()).toEqual({ "x-brand": "test" }); }); test("should withstand refinements", () => { const schema = z.string(); const schemaWithMeta = schema.brand("test"); - expect(getBrand(schemaWithMeta)).toBe("test"); - expect(getBrand(schemaWithMeta.regex(/@example.com$/))).toBe("test"); + expect(schemaWithMeta.meta()).toEqual({ "x-brand": "test" }); + expect(schemaWithMeta.regex(/@example.com$/).meta()).toEqual({ + "x-brand": "test", + }); }); test("should withstand describing", () => { const schema = z.string().brand("test").describe("something"); - expect(getBrand(schema)).toBe("test"); + expect(schema.meta()).toEqual({ + "x-brand": "test", + description: "something", + }); }); });