DEV Community

Cover image for I Built an npm Package to Solve a Date Validation Problem Every Enterprise Form Developer Faces
Martins Okafor
Martins Okafor

Posted on

I Built an npm Package to Solve a Date Validation Problem Every Enterprise Form Developer Faces

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')
}
Enter fullscreen mode Exit fullscreen mode

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)  // {}
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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(),
  ))
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'    },
])
Enter fullscreen mode Exit fullscreen mode

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'   },
    ])
  )
Enter fullscreen mode Exit fullscreen mode

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 exactOptionalPropertyTypes and noUncheckedIndexedAccess
  • 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
Enter fullscreen mode Exit fullscreen mode

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)