Every enterprise application I've worked on has the same quiet frustration lurking inside it.
You have a form with multiple date fields. A start date, a review date, and an end date. A contract open date, an approval date, and a close date. A phase one deadline, a phase two deadline, a phase three deadline. The exact labels don't matter — the problem is always the same: these dates must be in order, and someone has to validate that.
And every team I've ever seen solve this problem solves it badly.
The Problem Nobody Talks About
Here's what usually happens. A developer writes something like this:
if (reviewDate < startDate) {
setError('reviewDate', 'Review date must be after start date')
}
if (endDate < reviewDate) {
setError('endDate', 'End date must be after review date')
}
Fine for two dates. But what happens when there are six? Eight? What happens when some fields are optional? What happens when a date is null because the user hasn't filled it in yet — should that fail validation or be skipped? What happens when the dates come from different timezones and your naive < comparison produces false errors because of how JavaScript parses date strings?
I've seen these bugs in production. I've written these bugs in production. And I've fixed them — multiple times, in multiple codebases — by writing the same bespoke hook over and over again.
So I extracted it, polished it, and published it as an npm package: chronologic-validator.
What It Does
At its core, chronologic-validator takes an ordered array of date fields and validates that each one comes after the previous one.
import { validateDateSequence } from 'chronologic-validator'
const result = validateDateSequence([
{ key: 'startDate', label: 'Start Date', value: '2024-01-15' },
{ key: 'reviewDate', label: 'Review Date', value: '2024-03-01' },
{ key: 'endDate', label: 'End Date', value: '2024-06-30' },
])
console.log(result.valid) // true
console.log(result.errors) // {}
When dates are out of order:
const result = validateDateSequence([
{ key: 'startDate', label: 'Start Date', value: '2024-06-01' },
{ key: 'endDate', label: 'End Date', value: '2024-01-01' },
])
console.log(result.valid) // false
console.log(result.errors.endDate) // 'End Date must be after Start Date'
console.log(result.results[1].reason) // 'ORDER_VIOLATION'
That's the happy path. But the interesting design decisions live in the edge cases.
The Design Decisions That Mattered
1. Timezone-Safe Date Normalisation
JavaScript date comparison has a subtle trap that I've seen bite teams more times than I can count.
new Date('2024-03-15') < new Date('2024-03-15T01:00:00')
// true — in some timezones
Two dates that represent the same day can compare differently depending on the timezone of the machine parsing them. If you're doing day-level date validation (which you almost always are in form fields), this produces false ordering violations.
The fix is to normalise all dates to midnight UTC before comparing them:
function normaliseDate(input: DateInput): Date | null {
// ...parse the date...
return new Date(Date.UTC(
date.getFullYear(),
date.getMonth(),
date.getDate(),
))
}
chronologic-validator does this for every input automatically. You pass in a Date object, an ISO string, or a timestamp — the library handles the normalisation.
2. The skipNull Option
This was the most consequential design decision in the library.
Enterprise forms are rarely fully filled in at once. Users save progress. They come back. They fill in some milestones and leave others blank. If a field is null, what should happen?
The default (skipNull: true) silently skips null fields and continues validating the sequence from the last non-null value. This is the right default for progressive form completion — a blank middle field doesn't break validation for the fields around it.
validateDateSequence([
{ key: 'phase1', label: 'Phase 1', value: '2024-01-01' },
{ key: 'phase2', label: 'Phase 2', value: null }, // skipped
{ key: 'phase3', label: 'Phase 3', value: '2024-06-01' },
])
// valid: true — phase1 and phase3 are compared directly
But when a form requires all fields to be filled (like a submission step), you want skipNull: false:
validateDateSequence(
[{ key: 'phase1', label: 'Phase 1', value: null }],
{ skipNull: false }
)
// errors.phase1: 'Phase 1 is required and cannot be empty'
// results[0].reason: 'NULL_VALUE'
Notice the reason field. This is a discriminated union — NULL_VALUE or ORDER_VIOLATION — so your UI code can respond differently to "this field is empty" versus "this field is in the wrong order." A required field indicator is different from an ordering error icon.
3. No Cascading Errors
Imagine three date fields: Phase 1, Phase 2, Phase 3. The user enters Phase 2 incorrectly — it's before Phase 1. Should Phase 3 also fail?
With naive validation, yes — Phase 3 is compared against the last valid date (Phase 1), so if Phase 3 comes after Phase 1 but before the (wrong) Phase 2, it might also fail.
chronologic-validator advances the comparison cursor regardless of whether the previous field was valid. So Phase 3 is compared against Phase 2's value (the wrong one), not Phase 1. This prevents a single mistake from flooding the form with errors for every subsequent field.
validateDateSequence([
{ key: 'phase1', label: 'Phase 1', value: '2024-09-01' },
{ key: 'phase2', label: 'Phase 2', value: '2024-01-01' }, // wrong
{ key: 'phase3', label: 'Phase 3', value: '2024-06-01' }, // after phase2's value
])
// errors.phase2 is defined
// errors.phase3 is undefined — no cascade
Framework Adapters
The core is completely framework-agnostic. But most people using this will be working in React, so I've included two adapters as separate sub-path exports.
React Hook Form
import { useChronologicalValidation } from 'chronologic-validator/react-hook-form'
const { errors, isValid } = useChronologicalValidation(control, [
{ key: 'startDate', label: 'Start Date', name: 'startDate' },
{ key: 'reviewDate', label: 'Review Date', name: 'reviewDate' },
{ key: 'endDate', label: 'End Date', name: 'endDate' },
])
The hook uses useWatch internally so it re-validates on every change. No wiring required.
Zod
import { chronologicalRefinement } from 'chronologic-validator/zod'
const schema = z
.object({
startDate: z.string().nullable(),
endDate: z.string().nullable(),
})
.superRefine(
chronologicalRefinement([
{ key: 'startDate', label: 'Start Date' },
{ key: 'endDate', label: 'End Date' },
])
)
Errors are attached directly to the failing field paths, so it works seamlessly with zodResolver from @hookform/resolvers.
The Stack
For anyone interested in how the package itself is built:
-
TypeScript in strict mode with
exactOptionalPropertyTypesandnoUncheckedIndexedAccess - tsup for building ESM and CJS simultaneously
- Vitest for tests (90%+ coverage threshold enforced in CI)
- Changesets for versioning and changelog generation
- GitHub Actions for CI across Node 18, 20, and 22, and automated publishing with npm provenance
The dual ESM/CJS output means it works in both modern bundler setups and legacy CommonJS environments without any configuration. The adapter sub-paths (chronologic-validator/react-hook-form, chronologic-validator/zod) are tree-shakeable — if you don't use Zod, you don't pay for it.
Install It
npm install chronologic-validator
The core has zero dependencies. Adapters use their respective peer dependencies.
Issues, PRs, and feedback are all welcome. If you've built something similar and solved these edge cases differently, I'd genuinely love to hear about it in the comments.
Martins Okafor is a Staff Software Engineer based in the UK, working at the intersection of enterprise software and digital transformation.
Top comments (0)