From 8503d337db19a72c54c53615dd84f52ab3b8fd22 Mon Sep 17 00:00:00 2001 From: Ian Macalinao Date: Tue, 30 Jun 2026 03:36:29 +0800 Subject: [PATCH] feat(temporal-zod): support custom validation errors (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an .error({ error }) method to every z/zInstance validator that returns a copy with a customized error, taking Zod's params object ({ error } — a string or error-map function). Non-breaking: the plain validators are unchanged and used directly as before. - temporalValidators(cls, { extraInputs?, error? }) threads error into the z.union + z.instanceof (what Zod 4 actually surfaces; the default error is unchanged at "Invalid input") - withError() augments a schema with a chainable .error() via Object.assign (verified safe: no collision, parses in z.object, toJSONSchema intact) - json-schemas reuses each base validator's .error() to rebuild, re-applying JSON Schema metadata (default keeps its $def; custom variants inline an equivalent schema) - New public types: TemporalError, TemporalErrorParams, WithError, TemporalValidator, TemporalInstanceValidator - Tests cover string/function errors, nesting, unchanged defaults, and JSON-Schema metadata; README + minor changeset updated --- .changeset/temporal-zod-custom-errors.md | 22 ++ bun.lock | 1 + packages/temporal-zod/README.md | 26 +- packages/temporal-zod/src/base/duration.ts | 21 +- packages/temporal-zod/src/base/index.test.ts | 66 ++++ packages/temporal-zod/src/base/index.ts | 9 +- packages/temporal-zod/src/base/instant.ts | 25 +- .../temporal-zod/src/base/plain-date-time.ts | 22 +- packages/temporal-zod/src/base/plain-date.ts | 21 +- .../temporal-zod/src/base/plain-month-day.ts | 22 +- packages/temporal-zod/src/base/plain-time.ts | 21 +- .../temporal-zod/src/base/plain-year-month.ts | 24 +- .../src/base/temporal-validator.ts | 83 ++++- .../temporal-zod/src/base/zoned-date-time.ts | 22 +- packages/temporal-zod/src/json-schema.test.ts | 45 +++ packages/temporal-zod/src/json-schemas.ts | 343 ++++++++---------- 16 files changed, 506 insertions(+), 267 deletions(-) create mode 100644 .changeset/temporal-zod-custom-errors.md diff --git a/.changeset/temporal-zod-custom-errors.md b/.changeset/temporal-zod-custom-errors.md new file mode 100644 index 0000000..544de49 --- /dev/null +++ b/.changeset/temporal-zod-custom-errors.md @@ -0,0 +1,22 @@ +--- +"temporal-zod": minor +--- + +Support custom validation errors (#54). + +Every `z` / `zInstance` validator now has an `.error(...)` method that +returns a copy of the validator with a customized error, taking Zod's params +object (`{ error }`, a string or an error-map function): + +```typescript +zPlainDate.error({ error: "Invalid date" }); +zPlainDate.error({ error: (issue) => `Bad: ${issue.input}` }); + +z.object({ date: zPlainDate.error({ error: "Invalid date" }) }); +``` + +This is **non-breaking** — the plain validators (`zPlainDate`, etc.) are unchanged +and keep their default error. The custom error is reported for any invalid input +(a malformed string or the wrong type), works on both the coercing and instance +validators, and is available on the main `temporal-zod` entry and +`temporal-zod/base`. JSON Schema output for the default validators is unchanged. diff --git a/bun.lock b/bun.lock index ee76436..679aa6d 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "monorepo", diff --git a/packages/temporal-zod/README.md b/packages/temporal-zod/README.md index 55c4744..431e4f7 100644 --- a/packages/temporal-zod/README.md +++ b/packages/temporal-zod/README.md @@ -8,7 +8,7 @@ This depends on the [temporal-polyfill](https://www.npmjs.com/package/temporal-p ## Usage -This library exports two Zod validators for each Temporal type: one with type coercion and one without. +This library exports two Zod validators for each Temporal type: one with type coercion and one without. Use them directly as schemas, or call `.error({ error })` on any of them for a copy with a custom error message (see [Custom errors](#custom-errors)). Strings are coerced to the appropriate Temporal type, and for the `Instant` type, `Date` objects are also coerced to `Instant` objects. @@ -41,6 +41,26 @@ const result = schema.parse(input); You may view the [tests](https://github.com/macalinao/temporal-utils/blob/master/packages/temporal-zod/src/index.test.ts) for more examples. +### Custom errors + +Call `.error(...)` on any validator to get a copy with a custom validation error, passing Zod's [`error` param](https://zod.dev/error-customization) — either a string or an error-map function: + +```typescript +import * as z from "zod"; +import { zPlainDate } from "temporal-zod"; + +const schema = z.object({ + // string message + start: zPlainDate.error({ error: "Please provide a valid start date" }), + // error-map function + end: zPlainDate.error({ + error: (issue) => `"${String(issue.input)}" is not a valid date`, + }), +}); +``` + +The custom error is reported for any invalid input (a malformed string or the wrong type), and works on both the coercing and instance validators. The plain `zPlainDate` keeps its default error. + ### JSON Schema Support The default `temporal-zod` export registers JSON Schema metadata on every validator via Zod's `.meta()`, so `z.toJSONSchema()` works out of the box: @@ -65,9 +85,11 @@ If you don't need JSON Schema support, you can import from `temporal-zod/base` f ```typescript import { zPlainDate, zInstant } from "temporal-zod/base"; + +const schema = z.object({ date: zPlainDate }); ``` -This is backwards-compatible with the pre-JSON Schema versions of `temporal-zod`. +These are the same validators (including `.error(...)`), just without the JSON Schema metadata registration side effect. ### With tRPC diff --git a/packages/temporal-zod/src/base/duration.ts b/packages/temporal-zod/src/base/duration.ts index f145086..96982de 100644 --- a/packages/temporal-zod/src/base/duration.ts +++ b/packages/temporal-zod/src/base/duration.ts @@ -1,7 +1,9 @@ -import type { z } from "zod"; -import type { ZodTemporal } from "./temporal-validator.js"; +import type { + TemporalInstanceValidator, + TemporalValidator, +} from "./temporal-validator.js"; import { Temporal } from "temporal-polyfill"; -import { temporalValidators } from "./temporal-validator.js"; +import { temporalValidators, withError } from "./temporal-validator.js"; export const Duration: typeof Temporal.Duration = Temporal.Duration; @@ -14,15 +16,18 @@ export const Duration: typeof Temporal.Duration = Temporal.Duration; export const DURATION_PATTERN = "^-?P(\\d+Y)?(\\d+M)?(\\d+W)?(\\d+D)?(T(\\d+H)?(\\d+M)?((\\d+(\\.\\d+)?)S)?)?$"; -const validators = temporalValidators(Duration); - /** * Validates or coerces a string to a {@link Temporal.Duration}. + * + * Use it directly, or call `.error({ error })` for a copy with a custom error + * (e.g. `zDuration.error({ error: "Invalid duration" })`). */ -export const zDuration: ZodTemporal = validators.coerce; +export const zDuration: TemporalValidator = withError( + (error) => temporalValidators(Duration, { error }).coerce, +); /** * Validates that the value is an instance of {@link Temporal.Duration}. */ -export const zDurationInstance: z.ZodType = - validators.instance; +export const zDurationInstance: TemporalInstanceValidator = + withError((error) => temporalValidators(Duration, { error }).instance); diff --git a/packages/temporal-zod/src/base/index.test.ts b/packages/temporal-zod/src/base/index.test.ts index 23e125a..a9ae9b3 100644 --- a/packages/temporal-zod/src/base/index.test.ts +++ b/packages/temporal-zod/src/base/index.test.ts @@ -130,3 +130,69 @@ describe("Temporal Zod Schemas", () => { expect(result.success).toBe(false); }); }); + +describe("custom errors", () => { + test("string error customizes the message for a bad string", () => { + const result = zPlainDate.error({ error: "Bad date" }).safeParse("nope"); + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.message).toBe("Bad date"); + }); + + test("string error customizes the message for a wrong type", () => { + const result = zPlainDate.error({ error: "Bad date" }).safeParse(123); + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.message).toBe("Bad date"); + }); + + test("function error receives the issue", () => { + const result = zInstant + .error({ + error: (issue) => `Bad instant: ${String(issue.input)}`, + }) + .safeParse("nope"); + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.message).toBe("Bad instant: nope"); + }); + + test("custom error on the instance validator", () => { + const result = zPlainDateInstance + .error({ error: "Not a PlainDate" }) + .safeParse("2023-01-01"); + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.message).toBe("Not a PlainDate"); + }); + + test("custom error works nested in z.object", () => { + const schema = z.object({ date: zPlainDate.error({ error: "Bad date" }) }); + const ok = schema.safeParse({ date: "2023-01-01" }); + expect(ok.success).toBe(true); + const bad = schema.safeParse({ date: "nope" }); + expect(bad.success).toBe(false); + expect(bad.error?.issues[0]?.message).toBe("Bad date"); + }); + + test("the default validator message is unchanged", () => { + const result = zPlainDate.safeParse("nope"); + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.message).toBe("Invalid input"); + }); + + test("a custom error still coerces valid input", () => { + const result = zPlainDate + .error({ error: "Bad date" }) + .safeParse("2023-01-01"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(Temporal.PlainDate.from("2023-01-01")); + } + }); + + test("Instant still coerces a Date with a custom error set", () => { + const date = new Date("2023-01-01T00:00:00Z"); + const result = zInstant.error({ error: "Bad instant" }).safeParse(date); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.epochMilliseconds).toBe(date.getTime()); + } + }); +}); diff --git a/packages/temporal-zod/src/base/index.ts b/packages/temporal-zod/src/base/index.ts index 0ea3492..b2dd437 100644 --- a/packages/temporal-zod/src/base/index.ts +++ b/packages/temporal-zod/src/base/index.ts @@ -16,7 +16,14 @@ * @module * @see {@link https://github.com/macalinao/temporal-utils/tree/master/packages/temporal-zod | temporal-zod on GitHub} */ -export type { ZodTemporal } from "./temporal-validator.js"; +export type { + TemporalError, + TemporalErrorParams, + TemporalInstanceValidator, + TemporalValidator, + WithError, + ZodTemporal, +} from "./temporal-validator.js"; export * from "./duration.js"; export * from "./instant.js"; export * from "./plain-date.js"; diff --git a/packages/temporal-zod/src/base/instant.ts b/packages/temporal-zod/src/base/instant.ts index b488fe4..0446fd6 100644 --- a/packages/temporal-zod/src/base/instant.ts +++ b/packages/temporal-zod/src/base/instant.ts @@ -1,6 +1,10 @@ +import type { + TemporalInstanceValidator, + TemporalValidator, +} from "./temporal-validator.js"; import { Temporal } from "temporal-polyfill"; import * as z from "zod"; -import { temporalValidators } from "./temporal-validator.js"; +import { temporalValidators, withError } from "./temporal-validator.js"; export const Instant: typeof Temporal.Instant = Temporal.Instant; @@ -14,21 +18,28 @@ export const Instant: typeof Temporal.Instant = Temporal.Instant; export const INSTANT_PATTERN = "^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])T([01]\\d|2[0-3]):[0-5]\\d(:[0-5]\\d(\\.\\d{1,9})?)?(Z|[+-]([01]\\d|2[0-3]):[0-5]\\d)(\\[.+\\])?$"; -const validators = temporalValidators(Instant, [ +const extraInputs = [ z .date() .transform((value) => Temporal.Instant.fromEpochMilliseconds(value.getTime()), ), -]); +]; /** - * Validates or coerces a string or Date to a {@link Temporal.Instant}. + * Validates or coerces a string or `Date` to a {@link Temporal.Instant}. + * + * Use it directly, or call `.error({ error })` for a copy with a custom error + * (e.g. `zInstant.error({ error: "Invalid instant" })`). */ -export const zInstant: z.ZodType = validators.coerce; +export const zInstant: TemporalValidator = withError( + (error) => temporalValidators(Instant, { extraInputs, error }).coerce, +); /** * Validates that the value is an instance of {@link Temporal.Instant}. */ -export const zInstantInstance: z.ZodType = - validators.instance; +export const zInstantInstance: TemporalInstanceValidator = + withError( + (error) => temporalValidators(Instant, { extraInputs, error }).instance, + ); diff --git a/packages/temporal-zod/src/base/plain-date-time.ts b/packages/temporal-zod/src/base/plain-date-time.ts index 3af76a6..b62078b 100644 --- a/packages/temporal-zod/src/base/plain-date-time.ts +++ b/packages/temporal-zod/src/base/plain-date-time.ts @@ -1,7 +1,9 @@ -import type { z } from "zod"; -import type { ZodTemporal } from "./temporal-validator.js"; +import type { + TemporalInstanceValidator, + TemporalValidator, +} from "./temporal-validator.js"; import { Temporal } from "temporal-polyfill"; -import { temporalValidators } from "./temporal-validator.js"; +import { temporalValidators, withError } from "./temporal-validator.js"; export const PlainDateTime: typeof Temporal.PlainDateTime = Temporal.PlainDateTime; @@ -14,16 +16,18 @@ export const PlainDateTime: typeof Temporal.PlainDateTime = export const PLAIN_DATE_TIME_PATTERN = "^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])T([01]\\d|2[0-3]):[0-5]\\d(:[0-5]\\d(\\.\\d{1,9})?)?$"; -const validators = temporalValidators(PlainDateTime); - /** * Validates or coerces a string to a {@link Temporal.PlainDateTime}. + * + * Use it directly, or call `.error({ error })` for a copy with a custom error + * (e.g. `zPlainDateTime.error({ error: "Invalid date-time" })`). */ -export const zPlainDateTime: ZodTemporal = - validators.coerce; +export const zPlainDateTime: TemporalValidator = + withError((error) => temporalValidators(PlainDateTime, { error }).coerce); /** * Validates that the value is an instance of {@link Temporal.PlainDateTime}. */ -export const zPlainDateTimeInstance: z.ZodType = - validators.instance; +export const zPlainDateTimeInstance: TemporalInstanceValidator< + typeof PlainDateTime +> = withError((error) => temporalValidators(PlainDateTime, { error }).instance); diff --git a/packages/temporal-zod/src/base/plain-date.ts b/packages/temporal-zod/src/base/plain-date.ts index 998c3d9..c1ffe0a 100644 --- a/packages/temporal-zod/src/base/plain-date.ts +++ b/packages/temporal-zod/src/base/plain-date.ts @@ -1,7 +1,9 @@ -import type { z } from "zod"; -import type { ZodTemporal } from "./temporal-validator.js"; +import type { + TemporalInstanceValidator, + TemporalValidator, +} from "./temporal-validator.js"; import { Temporal } from "temporal-polyfill"; -import { temporalValidators } from "./temporal-validator.js"; +import { temporalValidators, withError } from "./temporal-validator.js"; export const PlainDate: typeof Temporal.PlainDate = Temporal.PlainDate; @@ -12,15 +14,18 @@ export const PlainDate: typeof Temporal.PlainDate = Temporal.PlainDate; export const PLAIN_DATE_PATTERN = "^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$"; -const validators = temporalValidators(PlainDate); - /** * Validates or coerces a string to a {@link Temporal.PlainDate}. + * + * Use it directly, or call `.error({ error })` for a copy with a custom error + * (e.g. `zPlainDate.error({ error: "Invalid date" })`). */ -export const zPlainDate: ZodTemporal = validators.coerce; +export const zPlainDate: TemporalValidator = withError( + (error) => temporalValidators(PlainDate, { error }).coerce, +); /** * Validates that the value is an instance of {@link Temporal.PlainDate}. */ -export const zPlainDateInstance: z.ZodType = - validators.instance; +export const zPlainDateInstance: TemporalInstanceValidator = + withError((error) => temporalValidators(PlainDate, { error }).instance); diff --git a/packages/temporal-zod/src/base/plain-month-day.ts b/packages/temporal-zod/src/base/plain-month-day.ts index e6e93ff..639b6c9 100644 --- a/packages/temporal-zod/src/base/plain-month-day.ts +++ b/packages/temporal-zod/src/base/plain-month-day.ts @@ -1,7 +1,9 @@ -import type { z } from "zod"; -import type { ZodTemporal } from "./temporal-validator.js"; +import type { + TemporalInstanceValidator, + TemporalValidator, +} from "./temporal-validator.js"; import { Temporal } from "temporal-polyfill"; -import { temporalValidators } from "./temporal-validator.js"; +import { temporalValidators, withError } from "./temporal-validator.js"; export const PlainMonthDay: typeof Temporal.PlainMonthDay = Temporal.PlainMonthDay; @@ -13,16 +15,18 @@ export const PlainMonthDay: typeof Temporal.PlainMonthDay = export const PLAIN_MONTH_DAY_PATTERN = "^(--)?(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$"; -const validators = temporalValidators(PlainMonthDay); - /** * Validates or coerces a string to a {@link Temporal.PlainMonthDay}. + * + * Use it directly, or call `.error({ error })` for a copy with a custom error + * (e.g. `zPlainMonthDay.error({ error: "Invalid month-day" })`). */ -export const zPlainMonthDay: ZodTemporal = - validators.coerce; +export const zPlainMonthDay: TemporalValidator = + withError((error) => temporalValidators(PlainMonthDay, { error }).coerce); /** * Validates that the value is an instance of {@link Temporal.PlainMonthDay}. */ -export const zPlainMonthDayInstance: z.ZodType = - validators.instance; +export const zPlainMonthDayInstance: TemporalInstanceValidator< + typeof PlainMonthDay +> = withError((error) => temporalValidators(PlainMonthDay, { error }).instance); diff --git a/packages/temporal-zod/src/base/plain-time.ts b/packages/temporal-zod/src/base/plain-time.ts index 78ab89f..fa27b13 100644 --- a/packages/temporal-zod/src/base/plain-time.ts +++ b/packages/temporal-zod/src/base/plain-time.ts @@ -1,7 +1,9 @@ -import type { z } from "zod"; -import type { ZodTemporal } from "./temporal-validator.js"; +import type { + TemporalInstanceValidator, + TemporalValidator, +} from "./temporal-validator.js"; import { Temporal } from "temporal-polyfill"; -import { temporalValidators } from "./temporal-validator.js"; +import { temporalValidators, withError } from "./temporal-validator.js"; export const PlainTime: typeof Temporal.PlainTime = Temporal.PlainTime; @@ -14,15 +16,18 @@ export const PlainTime: typeof Temporal.PlainTime = Temporal.PlainTime; export const PLAIN_TIME_PATTERN = "^([01]\\d|2[0-3]):[0-5]\\d(:[0-5]\\d(\\.\\d{1,9})?)?$"; -const validators = temporalValidators(PlainTime); - /** * Validates or coerces a string to a {@link Temporal.PlainTime}. + * + * Use it directly, or call `.error({ error })` for a copy with a custom error + * (e.g. `zPlainTime.error({ error: "Invalid time" })`). */ -export const zPlainTime: ZodTemporal = validators.coerce; +export const zPlainTime: TemporalValidator = withError( + (error) => temporalValidators(PlainTime, { error }).coerce, +); /** * Validates that the value is an instance of {@link Temporal.PlainTime}. */ -export const zPlainTimeInstance: z.ZodType = - validators.instance; +export const zPlainTimeInstance: TemporalInstanceValidator = + withError((error) => temporalValidators(PlainTime, { error }).instance); diff --git a/packages/temporal-zod/src/base/plain-year-month.ts b/packages/temporal-zod/src/base/plain-year-month.ts index 8eccb60..017d97b 100644 --- a/packages/temporal-zod/src/base/plain-year-month.ts +++ b/packages/temporal-zod/src/base/plain-year-month.ts @@ -1,7 +1,9 @@ -import type { z } from "zod"; -import type { ZodTemporal } from "./temporal-validator.js"; +import type { + TemporalInstanceValidator, + TemporalValidator, +} from "./temporal-validator.js"; import { Temporal } from "temporal-polyfill"; -import { temporalValidators } from "./temporal-validator.js"; +import { temporalValidators, withError } from "./temporal-validator.js"; export const PlainYearMonth: typeof Temporal.PlainYearMonth = Temporal.PlainYearMonth; @@ -12,16 +14,20 @@ export const PlainYearMonth: typeof Temporal.PlainYearMonth = */ export const PLAIN_YEAR_MONTH_PATTERN = "^\\d{4}-(0[1-9]|1[0-2])$"; -const validators = temporalValidators(PlainYearMonth); - /** * Validates or coerces a string to a {@link Temporal.PlainYearMonth}. + * + * Use it directly, or call `.error({ error })` for a copy with a custom error + * (e.g. `zPlainYearMonth.error({ error: "Invalid year-month" })`). */ -export const zPlainYearMonth: ZodTemporal = - validators.coerce; +export const zPlainYearMonth: TemporalValidator = + withError((error) => temporalValidators(PlainYearMonth, { error }).coerce); /** * Validates that the value is an instance of {@link Temporal.PlainYearMonth}. */ -export const zPlainYearMonthInstance: z.ZodType = - validators.instance; +export const zPlainYearMonthInstance: TemporalInstanceValidator< + typeof PlainYearMonth +> = withError( + (error) => temporalValidators(PlainYearMonth, { error }).instance, +); diff --git a/packages/temporal-zod/src/base/temporal-validator.ts b/packages/temporal-zod/src/base/temporal-validator.ts index bf67f53..fad150f 100644 --- a/packages/temporal-zod/src/base/temporal-validator.ts +++ b/packages/temporal-zod/src/base/temporal-validator.ts @@ -4,6 +4,30 @@ declare abstract class Class { constructor(..._: unknown[]); } +/** + * A custom error, mirroring Zod's `error` param (e.g. `z.string({ error })`). + * Either a string message or an error-map function that receives the issue. + */ +export type TemporalError = + | string + | ((issue: z.core.$ZodRawIssue) => string | undefined); + +/** + * Error-customization params accepted by `.error(...)`, matching + * Zod's construction params (`{ error }`). + */ +export interface TemporalErrorParams { + error?: TemporalError; +} + +/** + * A Zod schema augmented with a chainable `.error()` method that returns a copy + * of the schema with a customized validation error. + */ +export type WithError = S & { + error: (params: TemporalErrorParams) => WithError; +}; + /** * A Zod validator for a Temporal class which also parses string inputs. */ @@ -13,15 +37,33 @@ export type ZodTemporal< }, > = z.ZodType, InstanceType | string>; +/** + * A coercing Temporal validator with a `.error()` method for customizing the + * validation error. + */ +export type TemporalValidator< + TClass extends typeof Class & { + from: (arg: string) => InstanceType; + }, +> = WithError>; + +/** + * An instance-only Temporal validator with a `.error()` method. + */ +export type TemporalInstanceValidator< + TClass extends typeof Class & { + from: (arg: string) => InstanceType; + }, +> = WithError>>; + /** * Creates Zod validators for a Temporal class. * * @param cls - The Temporal class to validate. - * @param extraInputs - Additional Zod schemas to accept as coerce inputs - * (e.g. `z.date().transform(...)` for Instant). - * @returns Two Zod validators for the Temporal class: `coerce` for coercing - * strings to the Temporal class, and `instance` for validating that the - * value is an instance of the Temporal class. + * @param options - `extraInputs` adds extra coerce inputs (e.g. `z.date()` for + * Instant); `error` customizes the validation error (string or error-map + * function), matching Zod's `error` param. + * @returns `coerce` (coerces strings/instances) and `instance` (instances only). */ export function temporalValidators< TClass extends typeof Class & { @@ -29,12 +71,20 @@ export function temporalValidators< }, >( cls: TClass, - extraInputs?: z.ZodType>[], + options?: { + extraInputs?: z.ZodType>[]; + error?: TemporalError; + }, ): { coerce: ZodTemporal; instance: z.ZodType>; } { - const instance = z.instanceof(cls); + // Zod's union/instanceof report the surfaced error; thread `error` into both so + // a custom message appears for any invalid input (bad string or wrong type). + const params = + options?.error === undefined ? undefined : { error: options.error }; + + const instance = z.instanceof(cls, params); const members: z.ZodType>[] = [ instance, @@ -48,7 +98,7 @@ export function temporalValidators< return z.NEVER; } }), - ...(extraInputs ?? []), + ...(options?.extraInputs ?? []), ]; const coerce = z.union( @@ -56,7 +106,24 @@ export function temporalValidators< z.ZodType>, ...z.ZodType>[], ], + params, ) as ZodTemporal; return { instance, coerce }; } + +/** + * Augments a schema with a chainable `.error()` method. The schema is built once + * via `build()`; `.error({ error })` rebuilds it with the custom error so the + * default validator stays usable directly (e.g. in `z.object({ d: zPlainDate })`). + */ +export function withError( + build: (error?: TemporalError) => S, + error?: TemporalError, +): WithError { + const schema = build(error); + return Object.assign(schema, { + error: (params: TemporalErrorParams): WithError => + withError(build, params.error), + }) as WithError; +} diff --git a/packages/temporal-zod/src/base/zoned-date-time.ts b/packages/temporal-zod/src/base/zoned-date-time.ts index c82a142..734a7a3 100644 --- a/packages/temporal-zod/src/base/zoned-date-time.ts +++ b/packages/temporal-zod/src/base/zoned-date-time.ts @@ -1,7 +1,9 @@ -import type { z } from "zod"; -import type { ZodTemporal } from "./temporal-validator.js"; +import type { + TemporalInstanceValidator, + TemporalValidator, +} from "./temporal-validator.js"; import { Temporal } from "temporal-polyfill"; -import { temporalValidators } from "./temporal-validator.js"; +import { temporalValidators, withError } from "./temporal-validator.js"; export const ZonedDateTime: typeof Temporal.ZonedDateTime = Temporal.ZonedDateTime; @@ -16,16 +18,18 @@ export const ZonedDateTime: typeof Temporal.ZonedDateTime = export const ZONED_DATE_TIME_PATTERN = "^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])T([01]\\d|2[0-3]):[0-5]\\d(:[0-5]\\d(\\.\\d{1,9})?)?(Z|[+-]([01]\\d|2[0-3]):[0-5]\\d)\\[.+\\]$"; -const validators = temporalValidators(ZonedDateTime); - /** * Validates or coerces a string to a {@link Temporal.ZonedDateTime}. + * + * Use it directly, or call `.error({ error })` for a copy with a custom error + * (e.g. `zZonedDateTime.error({ error: "Invalid zoned date-time" })`). */ -export const zZonedDateTime: ZodTemporal = - validators.coerce; +export const zZonedDateTime: TemporalValidator = + withError((error) => temporalValidators(ZonedDateTime, { error }).coerce); /** * Validates that the value is an instance of {@link Temporal.ZonedDateTime}. */ -export const zZonedDateTimeInstance: z.ZodType = - validators.instance; +export const zZonedDateTimeInstance: TemporalInstanceValidator< + typeof ZonedDateTime +> = withError((error) => temporalValidators(ZonedDateTime, { error }).instance); diff --git a/packages/temporal-zod/src/json-schema.test.ts b/packages/temporal-zod/src/json-schema.test.ts index 2958962..badfeab 100644 --- a/packages/temporal-zod/src/json-schema.test.ts +++ b/packages/temporal-zod/src/json-schema.test.ts @@ -495,3 +495,48 @@ describe("regex patterns validate correctly", () => { }); } }); + +describe("custom errors preserve JSON Schema metadata", () => { + test("a custom-error validator produces the same JSON Schema", () => { + const base = z.toJSONSchema(zPlainDate); + const custom = z.toJSONSchema(zPlainDate.error({ error: "Bad date" })); + expect(custom).toEqual(base); + expect(custom).toMatchObject({ + type: "string", + id: "Temporal.PlainDate", + format: "date", + pattern: PLAIN_DATE_PATTERN, + }); + }); + + test("custom-error variant inlines an equivalent schema in z.object()", () => { + // The default validator registers the reusable $def (so it $refs); a + // custom-error variant is a distinct schema, so it inlines an identical + // JSON Schema rather than sharing the $def. + const schema = z.toJSONSchema( + z.object({ + a: zPlainDate, + b: zPlainDate.error({ error: "Bad date" }), + }), + ); + expect(schema).toMatchObject({ + type: "object", + properties: { + a: { $ref: "#/$defs/Temporal.PlainDate" }, + b: { + type: "string", + id: "Temporal.PlainDate", + format: "date", + pattern: PLAIN_DATE_PATTERN, + }, + }, + }); + }); + + test("the custom error is still reported at parse time", () => { + const schema = z.object({ date: zPlainDate.error({ error: "Bad date" }) }); + const result = schema.safeParse({ date: "nope" }); + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.message).toBe("Bad date"); + }); +}); diff --git a/packages/temporal-zod/src/json-schemas.ts b/packages/temporal-zod/src/json-schemas.ts index 59b83d5..2249a81 100644 --- a/packages/temporal-zod/src/json-schemas.ts +++ b/packages/temporal-zod/src/json-schemas.ts @@ -3,7 +3,8 @@ * * This is the main entry point of `temporal-zod`. Every validator exported here * is a clone of the corresponding base validator with JSON Schema metadata - * attached via Zod's `.meta()`, so `z.toJSONSchema()` works out of the box. + * attached via Zod's `.meta()`, so `z.toJSONSchema()` works out of the box. Each + * validator also has an `.error({ error })` method for customizing the error. * * If you don't need JSON Schema support, import from `temporal-zod/base` instead * for a smaller bundle without the metadata registration side effect. @@ -13,6 +14,7 @@ */ import type { Temporal } from "temporal-polyfill"; import type * as z from "zod"; +import type { WithError } from "./base/index.js"; import { DURATION_PATTERN, INSTANT_PATTERN, @@ -39,6 +41,7 @@ import { zZonedDateTime as zZonedDateTimeBase, zZonedDateTimeInstance as zZonedDateTimeInstanceBase, } from "./base/index.js"; +import { withError } from "./base/temporal-validator.js"; /** * Shape of the JSON Schema metadata attached to each Temporal validator. @@ -52,276 +55,238 @@ interface TemporalJSONSchema { } /** - * Applies JSON schema metadata to Zod schemas so `z.toJSONSchema()` works. - * - * Uses `.meta()` to clone each schema and register metadata in the global - * registry. The metadata (description, pattern, format) is `Object.assign`ed - * into the JSON Schema output by Zod's `toJSONSchema()`. - * - * Sets `_zod.toJSONSchema` on every clone to prevent "unrepresentable type" - * errors for instanceof/transform schemas. Only the first schema (coerce) - * gets `id` in metadata for `$defs/$ref` dedup in composed schemas. + * Attaches JSON Schema metadata to a Zod schema clone. + * + * Uses `.meta()` to register metadata in the global registry (the description, + * pattern, and format are `Object.assign`ed into the `toJSONSchema()` output). + * Sets `_zod.toJSONSchema` to prevent "unrepresentable type" errors for + * instanceof/transform schemas, and clears the cloned `_zod.parent` (which would + * otherwise crash `flattenRef`, since the original isn't in the seen map). + * + * @param withId - When `true`, the metadata includes `id` so the schema registers + * a reusable `$def`. Only the default coerce validator does this; instance and + * custom-error variants omit it (registering a duplicate id throws), so they + * reference that `$def` via `$ref` (or inline an equivalent schema). */ -function registerJSONSchema( - schemas: T, +function decorateJSONSchema( + schema: z.ZodType, jsonSchema: TemporalJSONSchema, -): T { + withId: boolean, +): z.ZodType { const { id, ...metaWithoutId } = jsonSchema; - return schemas.map((schema, i) => { - // First schema gets id in metadata for $defs/$ref dedup in composed schemas. - // Registering multiple schemas with the same id causes "Duplicate schema id" - // errors when both appear in the same z.object(). - const cloned = schema.meta(i === 0 ? jsonSchema : metaWithoutId); - // Override to prevent "unrepresentable type" errors for instanceof/transform - // schemas. Include id so standalone conversion includes it for all schemas. - cloned._zod.toJSONSchema = () => ({ type: jsonSchema.type, id }); - // Clear parent ref set by clone() — the original schema won't be in the - // toJSONSchema seen map, which causes flattenRef to crash. - cloned._zod.parent = undefined; - return cloned; - }) as unknown as T; + const cloned = schema.meta(withId ? jsonSchema : metaWithoutId); + cloned._zod.toJSONSchema = () => ({ type: jsonSchema.type, id }); + cloned._zod.parent = undefined; + return cloned; } -// Avoid destructured exports (`const [a, b] = ...`) which are incompatible -// with --isolatedDeclarations. Use indexed access with explicit type annotations. +/** + * Wraps a base validator with JSON Schema metadata, preserving its `.error()` + * method. The default is decorated once (the coerce validator registers the + * reusable `$def`); `.error({ error })` rebuilds from the base validator's own + * `.error()` and re-applies the metadata. + */ +function jsonValidator( + base: WithError>, + jsonSchema: TemporalJSONSchema, + isCoerce: boolean, +): WithError> { + return withError>((error) => + decorateJSONSchema( + error === undefined ? base : base.error({ error }), + jsonSchema, + error === undefined && isCoerce, + ), + ); +} -const _instant = registerJSONSchema([zInstantBase, zInstantInstanceBase], { +const INSTANT_JSON_SCHEMA: TemporalJSONSchema = { type: "string", id: "Temporal.Instant", description: "An ISO 8601 instant string with a required UTC offset (e.g. 2023-01-15T13:45:30Z)", format: "date-time", pattern: INSTANT_PATTERN, -}); +}; /** * Validates or coerces a string or `Date` to a {@link Temporal.Instant}. * - * Accepts ISO 8601 instant strings with a required UTC offset - * (e.g. `2023-01-15T13:45:30Z`), `Date` objects, or existing `Instant` instances. - * - * Includes JSON Schema metadata (`format: "date-time"`, `pattern`, `description`) - * so `z.toJSONSchema()` produces a correct JSON Schema. + * Use it directly, or call `.error({ error })` for a copy with a custom error. + * Includes JSON Schema metadata so `z.toJSONSchema()` works. */ -const zInstant: z.ZodType = _instant[0]; +const zInstant: WithError> = jsonValidator( + zInstantBase, + INSTANT_JSON_SCHEMA, + true, +); /** * Validates that the value is an instance of {@link Temporal.Instant}. * * Unlike {@link zInstant}, this does **not** coerce strings or `Date` objects. - * Use this when you expect a pre-parsed `Temporal.Instant` instance. - * - * Includes JSON Schema metadata so `z.toJSONSchema()` produces a correct JSON Schema. */ -const zInstantInstance: z.ZodType = _instant[1]; - -const _plainDate = registerJSONSchema( - [zPlainDateBase, zPlainDateInstanceBase], - { - type: "string", - id: "Temporal.PlainDate", - description: "An ISO 8601 date string without time (e.g. 2023-01-15)", - format: "date", - pattern: PLAIN_DATE_PATTERN, - }, +const zInstantInstance: WithError> = jsonValidator( + zInstantInstanceBase, + INSTANT_JSON_SCHEMA, + false, ); + +const PLAIN_DATE_JSON_SCHEMA: TemporalJSONSchema = { + type: "string", + id: "Temporal.PlainDate", + description: "An ISO 8601 date string without time (e.g. 2023-01-15)", + format: "date", + pattern: PLAIN_DATE_PATTERN, +}; /** * Validates or coerces a string to a {@link Temporal.PlainDate}. * - * Accepts ISO 8601 date strings without time (e.g. `2023-01-15`) - * or existing `PlainDate` instances. - * - * Includes JSON Schema metadata (`format: "date"`, `pattern`, `description`) - * so `z.toJSONSchema()` produces a correct JSON Schema. + * Use it directly, or call `.error({ error })` for a copy with a custom error. + * Includes JSON Schema metadata so `z.toJSONSchema()` works. */ -const zPlainDate: z.ZodType = _plainDate[0]; +const zPlainDate: WithError> = jsonValidator( + zPlainDateBase, + PLAIN_DATE_JSON_SCHEMA, + true, +); /** * Validates that the value is an instance of {@link Temporal.PlainDate}. - * - * Unlike {@link zPlainDate}, this does **not** coerce strings. - * Use this when you expect a pre-parsed `Temporal.PlainDate` instance. - * - * Includes JSON Schema metadata so `z.toJSONSchema()` produces a correct JSON Schema. */ -const zPlainDateInstance: z.ZodType = _plainDate[1]; +const zPlainDateInstance: WithError> = + jsonValidator(zPlainDateInstanceBase, PLAIN_DATE_JSON_SCHEMA, false); -const _plainTime = registerJSONSchema( - [zPlainTimeBase, zPlainTimeInstanceBase], - { - type: "string", - id: "Temporal.PlainTime", - description: - "An ISO 8601 time string without date or timezone (e.g. 13:45:30)", - pattern: PLAIN_TIME_PATTERN, - }, -); +const PLAIN_TIME_JSON_SCHEMA: TemporalJSONSchema = { + type: "string", + id: "Temporal.PlainTime", + description: + "An ISO 8601 time string without date or timezone (e.g. 13:45:30)", + pattern: PLAIN_TIME_PATTERN, +}; /** * Validates or coerces a string to a {@link Temporal.PlainTime}. * - * Accepts ISO 8601 time strings without date or timezone - * (e.g. `13:45:30`, `13:45:30.123456789`) or existing `PlainTime` instances. - * - * Includes JSON Schema metadata (`pattern`, `description`) - * so `z.toJSONSchema()` produces a correct JSON Schema. + * Use it directly, or call `.error({ error })` for a copy with a custom error. + * Includes JSON Schema metadata so `z.toJSONSchema()` works. */ -const zPlainTime: z.ZodType = _plainTime[0]; +const zPlainTime: WithError> = jsonValidator( + zPlainTimeBase, + PLAIN_TIME_JSON_SCHEMA, + true, +); /** * Validates that the value is an instance of {@link Temporal.PlainTime}. - * - * Unlike {@link zPlainTime}, this does **not** coerce strings. - * Use this when you expect a pre-parsed `Temporal.PlainTime` instance. - * - * Includes JSON Schema metadata so `z.toJSONSchema()` produces a correct JSON Schema. */ -const zPlainTimeInstance: z.ZodType = _plainTime[1]; +const zPlainTimeInstance: WithError> = + jsonValidator(zPlainTimeInstanceBase, PLAIN_TIME_JSON_SCHEMA, false); -const _plainDateTime = registerJSONSchema( - [zPlainDateTimeBase, zPlainDateTimeInstanceBase], - { - type: "string", - id: "Temporal.PlainDateTime", - description: - "An ISO 8601 date-time string without timezone (e.g. 2023-01-15T13:45:30)", - pattern: PLAIN_DATE_TIME_PATTERN, - }, -); +const PLAIN_DATE_TIME_JSON_SCHEMA: TemporalJSONSchema = { + type: "string", + id: "Temporal.PlainDateTime", + description: + "An ISO 8601 date-time string without timezone (e.g. 2023-01-15T13:45:30)", + pattern: PLAIN_DATE_TIME_PATTERN, +}; /** * Validates or coerces a string to a {@link Temporal.PlainDateTime}. * - * Accepts ISO 8601 date-time strings without timezone - * (e.g. `2023-01-15T13:45:30`) or existing `PlainDateTime` instances. - * - * Includes JSON Schema metadata (`pattern`, `description`) - * so `z.toJSONSchema()` produces a correct JSON Schema. + * Use it directly, or call `.error({ error })` for a copy with a custom error. + * Includes JSON Schema metadata so `z.toJSONSchema()` works. */ -const zPlainDateTime: z.ZodType = _plainDateTime[0]; +const zPlainDateTime: WithError> = + jsonValidator(zPlainDateTimeBase, PLAIN_DATE_TIME_JSON_SCHEMA, true); /** * Validates that the value is an instance of {@link Temporal.PlainDateTime}. - * - * Unlike {@link zPlainDateTime}, this does **not** coerce strings. - * Use this when you expect a pre-parsed `Temporal.PlainDateTime` instance. - * - * Includes JSON Schema metadata so `z.toJSONSchema()` produces a correct JSON Schema. */ -const zPlainDateTimeInstance: z.ZodType = - _plainDateTime[1]; +const zPlainDateTimeInstance: WithError> = + jsonValidator(zPlainDateTimeInstanceBase, PLAIN_DATE_TIME_JSON_SCHEMA, false); -const _plainYearMonth = registerJSONSchema( - [zPlainYearMonthBase, zPlainYearMonthInstanceBase], - { - type: "string", - id: "Temporal.PlainYearMonth", - description: "An ISO 8601 year-month string (e.g. 2023-01)", - pattern: PLAIN_YEAR_MONTH_PATTERN, - }, -); +const PLAIN_YEAR_MONTH_JSON_SCHEMA: TemporalJSONSchema = { + type: "string", + id: "Temporal.PlainYearMonth", + description: "An ISO 8601 year-month string (e.g. 2023-01)", + pattern: PLAIN_YEAR_MONTH_PATTERN, +}; /** * Validates or coerces a string to a {@link Temporal.PlainYearMonth}. * - * Accepts ISO 8601 year-month strings (e.g. `2023-01`) - * or existing `PlainYearMonth` instances. - * - * Includes JSON Schema metadata (`pattern`, `description`) - * so `z.toJSONSchema()` produces a correct JSON Schema. + * Use it directly, or call `.error({ error })` for a copy with a custom error. + * Includes JSON Schema metadata so `z.toJSONSchema()` works. */ -const zPlainYearMonth: z.ZodType = _plainYearMonth[0]; +const zPlainYearMonth: WithError> = + jsonValidator(zPlainYearMonthBase, PLAIN_YEAR_MONTH_JSON_SCHEMA, true); /** * Validates that the value is an instance of {@link Temporal.PlainYearMonth}. - * - * Unlike {@link zPlainYearMonth}, this does **not** coerce strings. - * Use this when you expect a pre-parsed `Temporal.PlainYearMonth` instance. - * - * Includes JSON Schema metadata so `z.toJSONSchema()` produces a correct JSON Schema. */ -const zPlainYearMonthInstance: z.ZodType = - _plainYearMonth[1]; +const zPlainYearMonthInstance: WithError> = + jsonValidator( + zPlainYearMonthInstanceBase, + PLAIN_YEAR_MONTH_JSON_SCHEMA, + false, + ); -const _plainMonthDay = registerJSONSchema( - [zPlainMonthDayBase, zPlainMonthDayInstanceBase], - { - type: "string", - id: "Temporal.PlainMonthDay", - description: "An ISO 8601 month-day string (e.g. --01-15 or 01-15)", - pattern: PLAIN_MONTH_DAY_PATTERN, - }, -); +const PLAIN_MONTH_DAY_JSON_SCHEMA: TemporalJSONSchema = { + type: "string", + id: "Temporal.PlainMonthDay", + description: "An ISO 8601 month-day string (e.g. --01-15 or 01-15)", + pattern: PLAIN_MONTH_DAY_PATTERN, +}; /** * Validates or coerces a string to a {@link Temporal.PlainMonthDay}. * - * Accepts ISO 8601 month-day strings (e.g. `--01-15` or `01-15`) - * or existing `PlainMonthDay` instances. - * - * Includes JSON Schema metadata (`pattern`, `description`) - * so `z.toJSONSchema()` produces a correct JSON Schema. + * Use it directly, or call `.error({ error })` for a copy with a custom error. + * Includes JSON Schema metadata so `z.toJSONSchema()` works. */ -const zPlainMonthDay: z.ZodType = _plainMonthDay[0]; +const zPlainMonthDay: WithError> = + jsonValidator(zPlainMonthDayBase, PLAIN_MONTH_DAY_JSON_SCHEMA, true); /** * Validates that the value is an instance of {@link Temporal.PlainMonthDay}. - * - * Unlike {@link zPlainMonthDay}, this does **not** coerce strings. - * Use this when you expect a pre-parsed `Temporal.PlainMonthDay` instance. - * - * Includes JSON Schema metadata so `z.toJSONSchema()` produces a correct JSON Schema. */ -const zPlainMonthDayInstance: z.ZodType = - _plainMonthDay[1]; +const zPlainMonthDayInstance: WithError> = + jsonValidator(zPlainMonthDayInstanceBase, PLAIN_MONTH_DAY_JSON_SCHEMA, false); -const _zonedDateTime = registerJSONSchema( - [zZonedDateTimeBase, zZonedDateTimeInstanceBase], - { - type: "string", - id: "Temporal.ZonedDateTime", - description: - "An ISO 8601 date-time string with timezone offset and IANA annotation (e.g. 2023-01-15T13:45:30+08:00[Asia/Manila])", - pattern: ZONED_DATE_TIME_PATTERN, - }, -); +const ZONED_DATE_TIME_JSON_SCHEMA: TemporalJSONSchema = { + type: "string", + id: "Temporal.ZonedDateTime", + description: + "An ISO 8601 date-time string with timezone offset and IANA annotation (e.g. 2023-01-15T13:45:30+08:00[Asia/Manila])", + pattern: ZONED_DATE_TIME_PATTERN, +}; /** * Validates or coerces a string to a {@link Temporal.ZonedDateTime}. * - * Accepts ISO 8601 date-time strings with a timezone offset and IANA timezone - * annotation (e.g. `2023-01-15T13:45:30+08:00[Asia/Manila]`) - * or existing `ZonedDateTime` instances. - * - * Includes JSON Schema metadata (`pattern`, `description`) - * so `z.toJSONSchema()` produces a correct JSON Schema. + * Use it directly, or call `.error({ error })` for a copy with a custom error. + * Includes JSON Schema metadata so `z.toJSONSchema()` works. */ -const zZonedDateTime: z.ZodType = _zonedDateTime[0]; +const zZonedDateTime: WithError> = + jsonValidator(zZonedDateTimeBase, ZONED_DATE_TIME_JSON_SCHEMA, true); /** * Validates that the value is an instance of {@link Temporal.ZonedDateTime}. - * - * Unlike {@link zZonedDateTime}, this does **not** coerce strings. - * Use this when you expect a pre-parsed `Temporal.ZonedDateTime` instance. - * - * Includes JSON Schema metadata so `z.toJSONSchema()` produces a correct JSON Schema. */ -const zZonedDateTimeInstance: z.ZodType = - _zonedDateTime[1]; +const zZonedDateTimeInstance: WithError> = + jsonValidator(zZonedDateTimeInstanceBase, ZONED_DATE_TIME_JSON_SCHEMA, false); -const _duration = registerJSONSchema([zDurationBase, zDurationInstanceBase], { +const DURATION_JSON_SCHEMA: TemporalJSONSchema = { type: "string", id: "Temporal.Duration", description: "An ISO 8601 duration string (e.g. PT1H30M, P1Y2M3D)", format: "duration", pattern: DURATION_PATTERN, -}); +}; /** * Validates or coerces a string to a {@link Temporal.Duration}. * - * Accepts ISO 8601 duration strings (e.g. `PT1H30M`, `P1Y2M3D`) - * or existing `Duration` instances. - * - * Includes JSON Schema metadata (`format: "duration"`, `pattern`, `description`) - * so `z.toJSONSchema()` produces a correct JSON Schema. + * Use it directly, or call `.error({ error })` for a copy with a custom error. + * Includes JSON Schema metadata so `z.toJSONSchema()` works. */ -const zDuration: z.ZodType = _duration[0]; +const zDuration: WithError> = jsonValidator( + zDurationBase, + DURATION_JSON_SCHEMA, + true, +); /** * Validates that the value is an instance of {@link Temporal.Duration}. - * - * Unlike {@link zDuration}, this does **not** coerce strings. - * Use this when you expect a pre-parsed `Temporal.Duration` instance. - * - * Includes JSON Schema metadata so `z.toJSONSchema()` produces a correct JSON Schema. */ -const zDurationInstance: z.ZodType = _duration[1]; +const zDurationInstance: WithError> = + jsonValidator(zDurationInstanceBase, DURATION_JSON_SCHEMA, false); export { zDuration,