Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 22 additions & 0 deletions .changeset/temporal-zod-custom-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"temporal-zod": minor
---

Support custom validation errors (#54).

Every `z<Type>` / `z<Type>Instance` 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.
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 24 additions & 2 deletions packages/temporal-zod/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand Down
21 changes: 13 additions & 8 deletions packages/temporal-zod/src/base/duration.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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<typeof Duration> = validators.coerce;
export const zDuration: TemporalValidator<typeof Duration> = withError(
(error) => temporalValidators(Duration, { error }).coerce,
);

/**
* Validates that the value is an instance of {@link Temporal.Duration}.
*/
export const zDurationInstance: z.ZodType<Temporal.Duration> =
validators.instance;
export const zDurationInstance: TemporalInstanceValidator<typeof Duration> =
withError((error) => temporalValidators(Duration, { error }).instance);
66 changes: 66 additions & 0 deletions packages/temporal-zod/src/base/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
});
});
9 changes: 8 additions & 1 deletion packages/temporal-zod/src/base/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
25 changes: 18 additions & 7 deletions packages/temporal-zod/src/base/instant.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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<Temporal.Instant> = validators.coerce;
export const zInstant: TemporalValidator<typeof Instant> = withError(
(error) => temporalValidators(Instant, { extraInputs, error }).coerce,
);

/**
* Validates that the value is an instance of {@link Temporal.Instant}.
*/
export const zInstantInstance: z.ZodType<Temporal.Instant> =
validators.instance;
export const zInstantInstance: TemporalInstanceValidator<typeof Instant> =
withError(
(error) => temporalValidators(Instant, { extraInputs, error }).instance,
);
22 changes: 13 additions & 9 deletions packages/temporal-zod/src/base/plain-date-time.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<typeof PlainDateTime> =
validators.coerce;
export const zPlainDateTime: TemporalValidator<typeof PlainDateTime> =
withError((error) => temporalValidators(PlainDateTime, { error }).coerce);

/**
* Validates that the value is an instance of {@link Temporal.PlainDateTime}.
*/
export const zPlainDateTimeInstance: z.ZodType<Temporal.PlainDateTime> =
validators.instance;
export const zPlainDateTimeInstance: TemporalInstanceValidator<
typeof PlainDateTime
> = withError((error) => temporalValidators(PlainDateTime, { error }).instance);
21 changes: 13 additions & 8 deletions packages/temporal-zod/src/base/plain-date.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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<typeof PlainDate> = validators.coerce;
export const zPlainDate: TemporalValidator<typeof PlainDate> = withError(
(error) => temporalValidators(PlainDate, { error }).coerce,
);

/**
* Validates that the value is an instance of {@link Temporal.PlainDate}.
*/
export const zPlainDateInstance: z.ZodType<Temporal.PlainDate> =
validators.instance;
export const zPlainDateInstance: TemporalInstanceValidator<typeof PlainDate> =
withError((error) => temporalValidators(PlainDate, { error }).instance);
22 changes: 13 additions & 9 deletions packages/temporal-zod/src/base/plain-month-day.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<typeof PlainMonthDay> =
validators.coerce;
export const zPlainMonthDay: TemporalValidator<typeof PlainMonthDay> =
withError((error) => temporalValidators(PlainMonthDay, { error }).coerce);

/**
* Validates that the value is an instance of {@link Temporal.PlainMonthDay}.
*/
export const zPlainMonthDayInstance: z.ZodType<Temporal.PlainMonthDay> =
validators.instance;
export const zPlainMonthDayInstance: TemporalInstanceValidator<
typeof PlainMonthDay
> = withError((error) => temporalValidators(PlainMonthDay, { error }).instance);
21 changes: 13 additions & 8 deletions packages/temporal-zod/src/base/plain-time.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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<typeof PlainTime> = validators.coerce;
export const zPlainTime: TemporalValidator<typeof PlainTime> = withError(
(error) => temporalValidators(PlainTime, { error }).coerce,
);

/**
* Validates that the value is an instance of {@link Temporal.PlainTime}.
*/
export const zPlainTimeInstance: z.ZodType<Temporal.PlainTime> =
validators.instance;
export const zPlainTimeInstance: TemporalInstanceValidator<typeof PlainTime> =
withError((error) => temporalValidators(PlainTime, { error }).instance);
Loading