8000 Add support for RFC 9557 + Temporal API by james-pre · Pull Request #5961 · colinhacks/zod · GitHub
[go: up one dir, main page]

Skip to content

Add support for RFC 9557 + Temporal API - #5961

Open
james-pre wants to merge 18 commits into
colinhacks:mainfrom
james-pre:temporal
Open

james-pre wants to merge 18 commits into
colinhacks:mainfrom
james-pre:temporal

Conversation

@james-pre
@james-pre james-pre commented May 6, 2026 •
Copy link
Copy Markdown

This PR adds support for RFC 9557 formatted dates using the now stable Temporal API (mdn).

The following zod types have been added:

zod type input output
z.instant() Temporal.InstantLike Temporal.Instant
z.plainDate() Temporal.PlainDateLike Temporal.PlainDate
z.plainDateTime() Temporal.PlainDateTimeLike Temporal.PlainDateTime
z.plainTime() Temporal.PlainTimeLike Temporal.PlainTime
z.plainYearMonth() Temporal.PlainYearMonthLike Temporal.PlainYearMonth
z.zonedDateTime() Temporal.ZonedDateTimeLike Temporal.ZonedDateTime

These temporal classes all have static from and compare methods, which are used internally.

For making sure objects are instances of Temporal API classes, using z.instanceof is more appropriate.

Additionally the schema types have methods for checking bounds: min, max, before, after, and equals.

Resolves #4016

Note that this requires Node v26 and Typescript 6.0+ in tests.

@pullfrog
pullfrog Bot commented May 6, 2026 •
Copy link
Copy Markdown
Contributor

TL;DR — Adds first-party Zod schemas for the stable Temporal API (RFC 9557), covering Instant, PlainDate, PlainDateTime, PlainTime, PlainYearMonth, and ZonedDateTime, with shared comparison checks (.min / .max / before / after / equals) and JSON Schema handling. Resolves #4016.

Key changes

  • New temporal schema type family — introduces $ZodTemporal in core plus six concrete subclasses ($ZodInstant, $ZodPlainDate, $ZodPlainDateTime, $ZodPlainTime, $ZodPlainYearMonth, $ZodZonedDateTime) built around a shared TemporalClass interface requiring static from, compare, and name, plus a constructor signature so instanceof def.class type-checks.
  • Classic factories z.instant() etc. — wires up ZodTemporal and six classic wrappers in packages/zod/src/v4/classic/schemas.ts with .min / .max / before / after / equals bound methods plus minimum / maximum accessors populated from bag. A comment notes that min/max are used instead of gt/lt/gte/lte because "less/greater than" doesn't read naturally for dates and times.
  • Mini mirrors — parallel ZodMiniInstant / ZodMiniPlainDate / ... exports added to packages/zod/src/v4/mini/schemas.ts, plus a compareTemporal re-export from packages/zod/src/v4/mini/checks.ts so the Temporal family is usable from mini.
  • Unified compareTemporal check — a single $ZodCheckTemporal accepts an array of acceptable compare results (e.g. [0, 1] for .min, [0, -1] for .max, [-1] for before, [1] for after, [0] for equals), and a new $ZodIssueInvalidTemporal issue reports expected as a pre-joined string like "equal or after" alongside an origin slot carrying the Temporal class name (e.g. "PlainDate") for richer messaging.
  • onattach hook populates bag.minimum / bag.maximum — the check writes the bound class instance onto bag at attach time, so the classic ZodTemporal constructor can derive minimum / maximum via def.class.from(bag.minimum) instead of reading raw def values. The latest commit fixes an inverted check (-1/1 were swapped) so .min(x).minimum and .max(x).maximum now populate as intended.
  • invalid_temporal coverage across all 46 locales — every shipped locale renders the new issue (e.g. English: "Invalid <origin>: expected value to be <expected> but it was <received>"), with non-English translations added as an LLM-translated pass.
  • JSON Schema behavior — a dedicated temporalProcessor throws when unrepresentable === "throw", otherwise silently drops Temporal schemas from JSON Schema output.
  • Tooling bumps — lib expanded to include esnext.temporal + esnext.date in .configs/tsconfig.base.json, and root typescript devDependency moved from ~5.5.4 to ^6.0.3. PR description notes tests require Node v26.

Summary | 65 files | 12 commits | base: main ← temporal


New $ZodTemporal core type

Before: Zod had no first-party schema for Temporal values; users had to hand-roll custom schemas and checks.
After: $ZodTemporal coerces input via def.class.from(input) on parse, emitting invalid_type with expected: "temporal" and only attaching received: "Invalid <ClassName>" when the input was already an instance of def.class (an arbitrary thrown value no longer produces a misleading received).

The base class is parameterised by Like / Instance types and a TemporalClass<Like, Instance> contract — any class exposing static from, compare, and name, plus a constructor, slots into the system. The added new (...args: any[]): Instance signature on TemporalClass is what lets the parse path do input instanceof def.class without a cast. The six concrete constructors default def.class via ??= before delegating to $ZodTemporal.init, so subclasses work whether the class is passed explicitly or implied by the factory.

packages/zod/src/v4/core/schemas.ts · packages/zod/src/v4/core/util.ts


Ordering checks via compareTemporal

Before: No range refinements existed for Temporal values.
After: Each schema exposes five ordering methods backed by a single $ZodCheckTemporal that calls def.class.compare(payload.value, def.value) and passes when the result is in an allowed set.

The check takes a results: (-1 | 0 | 1)[] array of acceptable compare outcomes:

Method results Semantics
.min(x) [0, 1] inclusive lower bound
.max(x) [0, -1] inclusive upper bound
after(x) [1] strictly greater than
before(x) [-1] strictly less than
equals(x) [0] exact match

A $ZodIssueInvalidTemporal is pushed with expected as a joined string (e.g. "equal or after", derived from a meaningMap of -1 | 0 | 1 → "before" | "equal" | "after"), received as the actual ordering, and origin carrying the Temporal class name so locale messages can render the type. The origin field is typed as a union of the six concrete class names ("Instant" | "PlainDate" | ...) widened with (string & {}). Unknown compare return values throw instead of silently passing.

The check's onattach handler writes bag.minimum / bag.maximum based on which results combination it was given — [0, 1] implies a minimum, [0, -1] implies a maximum. The classic ZodTemporal constructor then reads those back through def.class.from to expose minimum / maximum on the instance.

packages/zod/src/v4/core/checks.ts · packages/zod/src/v4/core/api.ts · packages/zod/src/v4/core/errors.ts


Public surface in classic and mini

Before: No z.instant / z.plainDate / ... entry points.
After: Classic exports z.instant(), z.plainDate(), z.plainDateTime(), z.plainTime(), z.plainYearMonth(), and z.zonedDateTime(), each returning a typed ZodTemporal with .min / .max / before / after / equals and minimum / maximum. Mini ships matching ZodMini* variants plus a compareTemporal re-export alongside the other mini check helpers.

The classic _ZodTemporal interface layers the five ordering methods and minimum / maximum on top of _ZodType, and every concrete Zod* interface narrows Like / Instance to the corresponding Temporal.*Like / Temporal.* pair. The firstparty type-level switch test is updated to acknowledge the new "temporal" def tag.

packages/zod/src/v4/classic/schemas.ts · packages/zod/src/v4/mini/schemas.ts · packages/zod/src/v4/mini/checks.ts · packages/zod/src/v4/classic/tests/firstparty.test.ts


Locale coverage for invalid_temporal

Before: Only en handled the new invalid_temporal issue; every other locale would fall through to its generic default.
After: All 46 shipped locales route invalid_temporal through a message of the form "Invalid <origin>: expected <expected> but got <received>" in the target language, using the origin field carrying the Temporal class name ("PlainDate", "ZonedDateTime", etc.) verbatim.

Non-English translations cover ar, az, be, bg, ca, cs, da, de, el, eo, es, fa, fi, fr, fr-CA, he, hr, hu, hy, id, is, it, ja, ka, km, ko, lt, mk, ms, nl, no, ota, pl, ps, pt, ro, ru, sl, sv, ta, th, tr, uk, ur, uz, vi, yo, zh-CN, and zh-TW. Translations interpolate the English class name directly rather than localising it.

packages/zod/src/v4/locales/en.ts · packages/zod/src/v4/locales/de.ts · packages/zod/src/v4/locales/ja.ts · packages/zod/src/v4/locales/zh-CN.ts


JSON Schema handling

Before: N/A — no Temporal schemas existed.
After: temporalProcessor short-circuits JSON Schema conversion: it throws "Temporal values cannot be represented in JSON Schema" when ctx.unrepresentable === "throw", and otherwise leaves the output empty.

This matches the existing pattern for types like date that have no clean JSON Schema representation.

packages/zod/src/v4/core/json-schema-processors.ts


Tests

Before: No Temporal coverage.
After: temporal.test.ts exercises parsing and .min / .max boundary behavior for all six schemas using RFC 9557 strings, plus an additional "additional temporal interactions" case covering before / after / equals, object-literal inputs, invalid inputs ("garbage", null, unrelated objects), the minimum / maximum getters, and the shape of the emitted invalid_temporal issue (e.g. expected: "after", received: "before").

The minimum / maximum assertions in that case are what surfaced the inverted onattach check fixed in 412e3a01. The PR description flags that the test suite requires Node v26 because of the Temporal global.

packages/zod/src/v4/classic/tests/temporal.test.ts

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus | 𝕏

@pullfrog pullfrog Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

Solid investigation on the Temporal integration, but there's a cluster of issues that should be resolved before this can merge. Three stand out: schema.min(x).minimum is always null because $ZodCheckTemporal never pushes an onattach to populate the bag; .min/.max are strictly exclusive, which diverges from the inclusive .min/.max of every other zod schema (including z.date()); and the ZodMini variants can't apply bounds at all because beforeTemporal/afterTemporal aren't exposed there. Inline comments cover the specifics.

A few broader calls worth making before a second pass:

Scope creep. Bumping typescript from ~5.5.4 to ^6.0.3 is unrelated to Temporal support — lib: ["esnext.temporal"] is available in TS 5.7+, no major bump required. Please revert the package.json change (and the resulting pnpm-lock.yaml churn) and do that bump as a separate PR if it's actually wanted.

Consumer type surface. The public .d.ts now references Temporal.InstantLike, Temporal.PlainDate, etc. unconditionally, which forces every downstream consumer's tsconfig to include esnext.temporal in lib — regardless of whether they ever call z.instant(). Users on older TS targets will see TS2503: Cannot find namespace 'Temporal' just from importing zod. Consider either (a) gating the new schemas behind a separate entry (e.g. zod/v4/temporal) so the core types stay Temporal-free, or (b) declaring an explicit TS peerDependency so the breakage is visible at install time.

Runtime requirement. Temporal only landed as a default Node global in v26.0.0 (one day old). On Node 24 LTS / Bun / Deno, calling z.instant() throws ReferenceError: Temporal is not defined. The factory references are all lazy (inside function bodies), so import-time is fine, but a better message when typeof Temporal === "undefined" would save users a lot of confusion.

API surface. Six new top-level factories (z.instant, z.plainDate, z.plainDateTime, z.plainTime, z.plainYearMonth, z.zonedDateTime) is a big expansion. TC39 keeps these under a single Temporal.* namespace; a z.temporal.instant() / z.temporal.plainDate() shape would mirror that and keep the top-level surface less crowded. Deferring to you on this — top-level names are hard to walk back after shipping.

Test coverage. The new suite only exercises string happy-paths and bound strictness. Worth adding before merge: object-input tests (.parse({ year: 2024, month: 1, day: 1 })), invalid-input tests (.safeParse("garbage"), .safeParse(null)), .minimum / .maximum getter tests (would have caught the onattach bug), issue-shape assertions for invalid_temporal, JSON-schema processor tests, and any mini coverage at all.

Linking #4016 for context.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using Claude Opus | 𝕏

Comment thread packages/zod/src/v4/core/checks.ts Resolved
Comment thread packages/zod/src/v4/core/checks.ts Resolved
Comment thread packages/zod/src/v4/core/checks.ts Resolved
Comment thread packages/zod/src/v4/core/checks.ts Resolved
Comment thread packages/zod/src/v4/mini/schemas.ts Resolved
Comment thread packages/zod/src/v4/core/schemas.ts Resolved
Comment thread packages/zod/src/v4/core/schemas.ts Resolved
Comment thread packages/zod/src/v4/core/errors.ts Resolved
Comment thread packages/zod/src/v4/core/util.ts Resolved
Comment thread packages/zod/src/v4/classic/schemas.ts Resolved
@pullfrog pullfrog Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

Nice work on the inclusive .min/.max semantics and the isst widening. Two issues introduced by this round of changes still worth resolving: the received label in the parse failure catch is now unconditional, which mislabels non-instance inputs (safeParse(null) now reports received: "Invalid Instant"), and $ZodIssueInvalidTemporal.expected is the only issue in the library that shapes expected as an array, which breaks the scalar-string contract every locale and third-party error map assumes.

Reviewed the following changes:

  • Merged _beforeTemporal + _afterTemporal into one _compareTemporal(Class, value, results, params) factory
  • Flipped .min/.max to inclusive by passing [0, 1] / [0, -1]
  • Changed $ZodCheckTemporalDef.result scalar to results: (-1 | 0 | 1)[]
  • Threw on non-{-1,0,1} comparison return instead of silently passing
  • Widened $ZodIssueInvalidTemporal.expected to ("before" | "equal" | "after")[]
  • Included $ZodIssueInvalidTemporal in $ZodTemporalInternals.isst
  • Dropped extends Function from TemporalClass and added name: string
  • Dropped the input instanceof def.class guard inside the parse catch
  • Asserted the benchmark-equal case parses under both .min and .max across all six temporal types

Prior review feedback:

  • $ZodCheckTemporal unreachable comparison branch now throws — addressed
  • result: -1 | 0 | 1 dead equal state — addressed via results array
  • .min/.max strictly exclusive — addressed, now inclusive
  • isst missing $ZodIssueInvalidTemporal — addressed
  • instanceof def.class unreachable in catch — addressed (removed)
  • TemporalClass extends Function loose — addressed

Still outstanding from the prior review (not re-commenting inline):

  • $ZodCheckTemporal needs an onattach that writes def.value into bag.minimum / bag.maximum — the eager snapshot at classic/schemas.ts:1406-1408 still reads undefined from the bag, so z.instant().min(x).minimum is always null.
  • No invalid_temporal case in any locale — failing bounds still fall through to "Invalid input".
  • compareTemporal still not re-exported from mini/checks.ts — ZodMini users have no path to apply bounds.
  • typescript bump from ~5.5.4 to ^6.0.3 is still in package.json (unrelated to Temporal support).
  • Test suite still missing object-input, invalid-input, .minimum/.maximum getter, and invalid_temporal issue-shape coverage.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using Claude Opus | 𝕏

Comment thread packages/zod/src/v4/core/schemas.ts Resolved
Comment thread packages/zod/src/v4/core/errors.ts Resolved
@pullfrog pullfrog Bot left a comment
8 Copy l 10BC8 ink
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new issues. Reviewed the following changes:

  • Added an onattach to $ZodCheckTemporal that writes bag.minimum / bag.maximum from def.value, so the eager snapshot in ZodTemporal.init now picks them up on the new schema returned by .check(...).

Prior review feedback:

  • z.instant().min(x).minimum always null — addressed

Most of the other outstanding items from the last round are still open (missing .minimum/.maximum getter test, no invalid_temporal locale entries, compareTemporal not re-exported from mini, typescript bump still in package.json, test suite still missing object-input / invalid-input / issue-shape coverage, received: "Invalid Instant" mislabel, and the expected: string[] divergence from the scalar contract).

Pullfrog  | Fix it ➔ | View workflow run | Using Claude Opus | 𝕏

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
@pullfrog pullfrog Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new issues. Reviewed the following changes:

  • Gated received: "Invalid <className>" behind input instanceof def.class in $ZodTemporal's parse catch, matching the pattern $ZodDate uses at schemas.ts:1600 so non-instance inputs (null, strings, plain objects) no longer get mislabeled as "Invalid Instant" / "Invalid PlainDate" / etc.

Prior review feedback:

  • received: "Invalid Instant" unconditional mislabel — addressed

Pullfrog  | Fix it ➔ | View workflow run | Using Claude Opus | 𝕏

@pullfrog pullfrog Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new issues. Reviewed the following changes:

  • Narrowed $ZodIssueInvalidTemporal.expected from ("before" | "equal" | "after")[] back to a scalar string, joining def.results with " or " in $ZodCheckTemporal so the issue shape matches the scalar-string contract every other zod issue uses.

Prior review feedback:

  • $ZodIssueInvalidTemporal.expected array divergence from scalar contract — addressed

Pullfrog  | Fix it ➔ | View workflow run | Using Claude Opus | 𝕏

Added `compareTemporal` export to mini
Added error handling to locale
@pullfrog pullfrog Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

New round cleared up some of the easier issues, but three new-commit items are worth addressing before merge: className on $ZodIssueInvalidTemporal breaks the origin convention every other issue subtype follows, only en got the new invalid_temporal entry (the other 50 locales fall through to "Invalid input"), and the .before/.after/.equals method names diverge from zod's .gt/.gte/.lt/.lte convention — with .equals(x) also failing to populate schema.minimum/schema.maximum as a side effect.

Reviewed the following changes:

  • Added .before/.after/.equals methods to _ZodTemporal in classic
  • Added className: string to $ZodIssueInvalidTemporal, populated from def.class.name
  • Added invalid_temporal case to the en locale (only)
  • Re-exported compareTemporal from mini/checks.ts

Prior review feedback:

  • compareTemporal not exposed in mini — addressed
  • No invalid_temporal case in any locale — partially addressed (only en; see inline)

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using Claude Opus | 𝕏

Comment thread packages/zod/src/v4/core/errors.ts Resolved
Comment thread packages/zod/src/v4/classic/schemas.ts Resolved
Comment thread packages/zod/src/v4/locales/en.ts Resolved
Fixed instanceof
Added note for check method name justification
@pullfrog pullfrog Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

Renaming className → origin is the right call, but the value is still populated from def.class.name, which yields PascalCase JS class names ("Instant", "PlainDate", ...). Every other $ZodIssue*.origin in core/errors.ts uses lowercase type tags ("number", "array", "map", "record", "set", "date", ...), and every ${issue.origin} interpolation across the locale files assumes that shape. The en.ts message now renders as "Invalid PlainDate: ...", which is the only locale line in the codebase that drops a bare PascalCase identifier into prose.

Reviewed the following changes:

  • Renamed className → origin on $ZodIssueInvalidTemporal, updating the push site in core/checks.ts and the interpolation in locales/en.ts
  • Widened $ZodIssueInvalidTemporal.origin to a PascalCase class-name union with (string & {}) fallback
  • Added new (...args: any[]): Instance to util.TemporalClass so input instanceof def.class type-checks at core/schemas.ts:1649
  • Added an inline comment justifying before/after/equals naming over gt/lt/gte/lte

Prior review feedback:

  • className divergence from origin convention — partially addressed (field renamed, but value shape still diverges; see inline)
  • TemporalClass extends Function looseness follow-up — addressed via explicit construct signature

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using Claude Opus | 𝕏

Comment thread packages/zod/src/v4/core/errors.ts Resolved
@pullfrog pullfrog Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new issues. Reviewed the following changes:

  • Added LLM-translated invalid_temporal cases to the remaining 49 locales, all interpolating ${issue.origin}, ${issue.expected}, and ${issue.received} into language-appropriate scaffolding

Prior review feedback:

  • invalid_temporal missing from non-en locales — addressed (note: the prior-round concern about ${issue.origin} rendering as PascalCase class names in prose now applies across all 50 locales, still unresolved)

Pullfrog  | Fix it ➔ | View workflow run | Using Claude Opus | 𝕏

Fixed bug in setting minimum/maximum
@james-pre
Copy link
Copy Markdown
Author

Tests are failing because the types and runtime support for Temporal are missing. As I said before this needs to be tested with TS 6.0+ and Node.js v26.

@pullfrog pullfrog Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new issues. Reviewed the following changes:

  • Swapped the bag.minimum / bag.maximum conditions in $ZodCheckTemporal.onattach so .min(x) (results [0, 1]) populates minimum and .max(x) (results [0, -1]) populates maximum. The previous mapping was inverted, which silently broke the getter for every chained .min/.max on a temporal schema.
  • Added an additional temporal interactions test covering .before / .after / .equals, object-input parse ({ year, month, day }), invalid inputs ("garbage", null, { nonsense: true }), .minimum / .maximum getter assertions after .min(earlier).max(later), and an invalid_temporal issue-shape check (expected: "after", received: "before").

Prior review feedback:

  • .minimum / .maximum getter test missing — addressed
  • Object-input tests missing — addressed
  • Invalid-input tests missing — addressed
  • invalid_temporal issue-shape assertions missing — addressed

Pullfrog  | Fix it ➔ | View workflow run | Using Claude Opus | 𝕏

@james-pre
Copy link
Copy Markdown
Author

Hey @colinhacks, could you take a look at this PR?

@infodusha
Copy link
Copy Markdown

Hint: if this is not possible to run tests on node 26 for some reason there are two polyfills available

@james-pre
Copy link
Copy Markdown
Author

@colinhacks,

It's been over a month since this PR was ready for review and merging, could you please take a look at it?

@sss-Mihail-sss
Copy link
Copy Markdown

any updates?

@james-pre
Copy link
Copy Markdown
Author

any updates?

Still waiting on @colinhacks for review and merge.

This branch was successfully deployed

1 active deployment
Preview – zod-v4 — b1824bea Deployed Sep 16, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RFC 9557 datetime validation

4 participants

0