Repository navigation
Conversation
|
TL;DR — Adds first-party Zod schemas for the stable Temporal API (RFC 9557), covering Key changes
Summary | 65 files | 12 commits | base: New
|
| 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 exportsz.instant(),z.plainDate(),z.plainDateTime(),z.plainTime(),z.plainYearMonth(), andz.zonedDateTime(), each returning a typedZodTemporalwith.min/.max/before/after/equalsandminimum/maximum. Mini ships matchingZodMini*variants plus acompareTemporalre-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
enhandled the newinvalid_temporalissue; every other locale would fall through to its generic default.
After: All 46 shipped locales routeinvalid_temporalthrough a message of the form"Invalid <origin>: expected <expected> but got <received>"in the target language, using theoriginfield 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:temporalProcessorshort-circuits JSON Schema conversion: it throws"Temporal values cannot be represented in JSON Schema"whenctx.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.tsexercises parsing and.min/.maxboundary behavior for all six schemas using RFC 9557 strings, plus an additional"additional temporal interactions"case coveringbefore/after/equals, object-literal inputs, invalid inputs ("garbage",null, unrelated objects), theminimum/maximumgetters, and the shape of the emittedinvalid_temporalissue (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
Claude Opus | 𝕏
There was a problem hiding this comment.
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.
Claude Opus | 𝕏
Fixed some error handling
There was a problem hiding this comment.
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+_afterTemporalinto one_compareTemporal(Class, value, results, params)factory - Flipped
.min/.maxto inclusive by passing[0, 1]/[0, -1] - Changed
$ZodCheckTemporalDef.resultscalar toresults: (-1 | 0 | 1)[] - Threw on non-{-1,0,1} comparison return instead of silently passing
- Widened
$ZodIssueInvalidTemporal.expectedto("before" | "equal" | "after")[] - Included
$ZodIssueInvalidTemporalin$ZodTemporalInternals.isst - Dropped
extends FunctionfromTemporalClassand addedname: string - Dropped the
input instanceof def.classguard inside the parse catch - Asserted the benchmark-equal case parses under both
.minand.maxacross all six temporal types
Prior review feedback:
-
$ZodCheckTemporalunreachable comparison branch now throws — addressed -
result: -1 | 0 | 1deadequalstate — addressed viaresultsarray -
.min/.maxstrictly exclusive — addressed, now inclusive -
isstmissing$ZodIssueInvalidTemporal— addressed -
instanceof def.classunreachable in catch — addressed (removed) -
TemporalClass extends Functionloose — addressed
Still outstanding from the prior review (not re-commenting inline):
$ZodCheckTemporalneeds anonattachthat writesdef.valueintobag.minimum/bag.maximum— the eager snapshot atclassic/schemas.ts:1406-1408still readsundefinedfrom the bag, soz.instant().min(x).minimumis alwaysnull.- No
invalid_temporalcase in any locale — failing bounds still fall through to"Invalid input". compareTemporalstill not re-exported frommini/checks.ts— ZodMini users have no path to apply bounds.typescriptbump from~5.5.4to^6.0.3is still inpackage.json(unrelated to Temporal support).- Test suite still missing object-input, invalid-input,
.minimum/.maximumgetter, andinvalid_temporalissue-shape coverage.
Claude Opus | 𝕏
There was a problem hiding this comment.
No new issues. Reviewed the following changes:
- Added an
onattachto$ZodCheckTemporalthat writesbag.minimum/bag.maximumfromdef.value, so the eager snapshot inZodTemporal.initnow picks them up on the new schema returned by.check(...).
Prior review feedback:
-
z.instant().min(x).minimumalwaysnull— 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).
Claude Opus | 𝕏
Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
There was a problem hiding this comment.
No new issues. Reviewed the following changes:
- Gated
received: "Invalid <className>"behindinput instanceof def.classin$ZodTemporal's parse catch, matching the pattern$ZodDateuses atschemas.ts:1600so 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
Claude Opus | 𝕏
There was a problem hiding this comment.
No new issues. Reviewed the following changes:
- Narrowed
$ZodIssueInvalidTemporal.expectedfrom("before" | "equal" | "after")[]back to a scalarstring, joiningdef.resultswith" or "in$ZodCheckTemporalso the issue shape matches the scalar-string contract every other zod issue uses.
Prior review feedback:
-
$ZodIssueInvalidTemporal.expectedarray divergence from scalar contract — addressed
Claude Opus | 𝕏
Added `compareTemporal` export to mini Added error handling to locale
There was a problem hiding this comment.
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/.equalsmethods to_ZodTemporalin classic - Added
className: stringto$ZodIssueInvalidTemporal, populated fromdef.class.name - Added
invalid_temporalcase to theenlocale (only) - Re-exported
compareTemporalfrommini/checks.ts
Prior review feedback:
-
compareTemporalnot exposed in mini — addressed - No
invalid_temporalcase in any locale — partially addressed (onlyen; see inline)
Claude Opus | 𝕏
Fixed instanceof Added note for check method name justification
There was a problem hiding this comment.
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→originon$ZodIssueInvalidTemporal, updating the push site incore/checks.tsand the interpolation inlocales/en.ts - Widened
$ZodIssueInvalidTemporal.originto a PascalCase class-name union with(string & {})fallback - Added
new (...args: any[]): Instancetoutil.TemporalClasssoinput instanceof def.classtype-checks atcore/schemas.ts:1649 - Added an inline comment justifying
before/after/equalsnaming overgt/lt/gte/lte
Prior review feedback:
-
classNamedivergence fromoriginconvention — partially addressed (field renamed, but value shape still diverges; see inline) -
TemporalClass extends Functionlooseness follow-up — addressed via explicit construct signature
Claude Opus | 𝕏
There was a problem hiding this comment.
No new issues. Reviewed the following changes:
- Added LLM-translated
invalid_temporalcases to the remaining 49 locales, all interpolating${issue.origin},${issue.expected}, and${issue.received}into language-appropriate scaffolding
Prior review feedback:
-
invalid_temporalmissing from non-enlocales — addressed (note: the prior-round concern about${issue.origin}rendering as PascalCase class names in prose now applies across all 50 locales, still unresolved)
Claude Opus | 𝕏
Fixed bug in setting minimum/maximum
|
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. |
There was a problem hiding this comment.
No new issues. Reviewed the following changes:
- Swapped the
bag.minimum/bag.maximumconditions in$ZodCheckTemporal.onattachso.min(x)(results[0, 1]) populatesminimumand.max(x)(results[0, -1]) populatesmaximum. The previous mapping was inverted, which silently broke the getter for every chained.min/.maxon a temporal schema. - Added an
additional temporal interactionstest covering.before/.after/.equals, object-input parse ({ year, month, day }), invalid inputs ("garbage",null,{ nonsense: true }),.minimum/.maximumgetter assertions after.min(earlier).max(later), and aninvalid_temporalissue-shape check (expected: "after",received: "before").
Prior review feedback:
-
.minimum/.maximumgetter test missing — addressed - Object-input tests missing — addressed
- Invalid-input tests missing — addressed
-
invalid_temporalissue-shape assertions missing — addressed
Claude Opus | 𝕏
|
Hey @colinhacks, could you take a look at this PR? |
|
Hint: if this is not possible to run tests on node 26 for some reason there are two polyfills available |
|
It's been over a month since this PR was ready for review and merging, could you please take a look at it? |
|
any updates? |
Still waiting on @colinhacks for review and merge. |

This PR adds support for RFC 9557 formatted dates using the now stable Temporal API (mdn).
The following zod types have been added:
z.instant()Temporal.InstantLikeTemporal.Instantz.plainDate()Temporal.PlainDateLikeTemporal.PlainDatez.plainDateTime()Temporal.PlainDateTimeLikeTemporal.PlainDateTimez.plainTime()Temporal.PlainTimeLikeTemporal.PlainTimez.plainYearMonth()Temporal.PlainYearMonthLikeTemporal.PlainYearMonthz.zonedDateTime()Temporal.ZonedDateTimeLikeTemporal.ZonedDateTimeThese temporal classes all have static
fromandcomparemethods, which are used internally.For making sure objects are instances of Temporal API classes, using
z.instanceofis more appropriate.Additionally the schema types have methods for checking bounds:
min,max,before,after, andequals.Resolves #4016
Note that this requires Node v26 and Typescript 6.0+ in tests.