Skip to content

martinschike/chronologic-validator

Repository files navigation

chronologic-validator

npm version npm downloads CI License: MIT TypeScript

Framework-agnostic TypeScript library for validating ordered date field sequences — with adapters for React Hook Form and Zod.


The Problem

Any form with multiple date fields that must follow a specific order has the same validation requirement: date A must come before date B, which must come before date C.

Project timelines, hiring pipelines, booking systems, contract periods, event scheduling — the problem is universal. Yet every team solves it differently: ad-hoc comparisons scattered through component logic, awkward Yup refinements, or bespoke hooks that aren't reusable.

chronologic-validator gives you a clean, composable, TypeScript-first solution with a framework-agnostic core and optional adapters for React Hook Form and Zod.


Features

  • Framework-agnostic core — works anywhere JavaScript runs
  • React Hook Form adapter — watches fields and re-validates on change
  • Zod adapter — compose with your existing schemas via superRefine
  • Configurable null handling — skip or surface errors for empty fields
  • Typed error reason codes — distinguish NULL_VALUE from ORDER_VIOLATION
  • Custom error message templates — full control over error text
  • Timezone-safe — all dates normalised to midnight UTC before comparison
  • No cascading errors — a single invalid field doesn't pollute the rest
  • Dual ESM/CJS output — works in all modern JavaScript environments
  • Tree-shakeable — adapters are separate sub-path exports; you only pay for what you use

Installation

npm install chronologic-validator

The core package has zero dependencies. Adapters require their respective peer dependencies:

# For the React Hook Form adapter
npm install react-hook-form

# For the Zod adapter
npm install zod

Quick Start

Core (framework-agnostic)

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' }, // ❌ before start
])

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'

React Hook Form Adapter

import { useForm } from 'react-hook-form'
import { useChronologicalValidation } from 'chronologic-validator/react-hook-form'

function ProjectForm() {
  const { control, register } = useForm({
    defaultValues: {
      startDate:  '',
      reviewDate: '',
      endDate:    '',
    },
  })

  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'    },
  ])

  return (
    <form>
      <div>
        <label>Start Date</label>
        <input type="date" {...register('startDate')} />
      </div>

      <div>
        <label>Review Date</label>
        <input type="date" {...register('reviewDate')} />
        {errors.reviewDate && <p>{errors.reviewDate}</p>}
      </div>

      <div>
        <label>End Date</label>
        <input type="date" {...register('endDate')} />
        {errors.endDate && <p>{errors.endDate}</p>}
      </div>

      <button type="submit" disabled={!isValid}>
        Submit
      </button>
    </form>
  )
}

Zod Adapter

import { z } from 'zod'
import { chronologicalRefinement } from 'chronologic-validator/zod'

const schema = z
  .object({
    startDate:  z.string().nullable(),
    reviewDate: z.string().nullable(),
    endDate:    z.string().nullable(),
  })
  .superRefine(
    chronologicalRefinement([
      { key: 'startDate',  label: 'Start Date'  },
      { key: 'reviewDate', label: 'Review Date' },
      { key: 'endDate',    label: 'End Date'    },
    ])
  )

const result = schema.safeParse({
  startDate:  '2024-01-15',
  reviewDate: '2024-03-01',
  endDate:    '2024-06-30',
})

console.log(result.success) // true

Or use the convenience wrapper for simpler cases:

import { chronologicalSchema } from 'chronologic-validator/zod'

const schema = chronologicalSchema([
  { key: 'from', label: 'From' },
  { key: 'to',   label: 'To'   },
])

API Reference

validateDateSequence(fields, options?)

The core validation function.

fields: DateField[]

An ordered array of date fields. The array order defines the expected chronological order.

interface DateField<K extends string = string> {
  key: K       // unique identifier — appears in the errors map
  label: string // human-readable name — used in error messages
  value: Date | string | number | null | undefined
}

The value field accepts Date objects, ISO strings, timestamps (numbers), null, and undefined. All values are normalised to midnight UTC before comparison.

options: ValidationOptions

Option Type Default Description
allowEqual boolean false When true, adjacent dates may be equal (same day). When false, strict ordering is enforced.
skipNull boolean true When true, null/undefined fields are silently skipped. When false, they produce an explicit error.
orderErrorTemplate string '{{label}} must be after {{prevLabel}}' Custom template for ordering errors. Use {{label}} and {{prevLabel}} as placeholders.
nullErrorTemplate string '{{label}} is required and cannot be empty' Custom template for null errors (only used when skipNull: false). Use {{label}} as a placeholder.

ValidationResult

interface ValidationResult<K extends string = string> {
  valid: boolean                        // true only if ALL fields passed
  results: FieldValidationResult<K>[]   // per-field results
  errors: Partial<Record<K, string>>    // map of failing keys to error messages
}

Each entry in results is a discriminated union:

type FieldValidationResult<K extends string = string> =
  | { key: K; valid: true;  error: null }
  | { key: K; valid: false; error: string; reason: ValidationErrorReason }

type ValidationErrorReason = 'NULL_VALUE' | 'ORDER_VIOLATION'

The reason field lets you handle different failure types differently in your UI.


Handling Null Fields

By default (skipNull: true), empty fields are silently skipped and the sequence continues from the last non-null value. This is ideal for forms where fields are filled in progressively.

// Phase 2 is empty — validation continues between Phase 1 and Phase 3
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' },
])
// result.valid === true

When skipNull: false, empty fields produce an explicit error with the reason code NULL_VALUE:

validateDateSequence(
  [{ key: 'phase1', label: 'Phase 1', value: null }],
  { skipNull: false }
)
// result.errors.phase1 === 'Phase 1 is required and cannot be empty'
// result.results[0].reason === 'NULL_VALUE'

Custom Error Messages

validateDateSequence(
  [
    { key: 'from', label: 'Contract Start', value: '2024-06-01' },
    { key: 'to',   label: 'Contract End',   value: '2024-01-01' },
  ],
  {
    orderErrorTemplate: '{{label}} must come after {{prevLabel}}',
    nullErrorTemplate:  '{{label}} cannot be left blank',
  }
)
// result.errors.to === 'Contract End must come after Contract Start'

Design Decisions

Timezone-safe date normalisation

All date inputs are normalised to midnight UTC (Date.UTC(year, month, day)) before comparison. This prevents the common off-by-one bugs that occur when comparing dates across different timezones — for example, new Date('2024-03-15') < new Date('2024-03-15T01:00:00') evaluates to true in some timezones, producing a false ordering violation.

No cascading errors

After an invalid field is detected, the comparison cursor still advances to that field's value. This means a single out-of-order date doesn't cause every subsequent field to also fail — only the genuinely invalid field is flagged.

Typed reason codes

The reason field (NULL_VALUE | ORDER_VIOLATION) lets consuming code handle different failure types differently — for example, showing a "required" indicator for null fields versus an ordering error icon for missequenced ones.

Framework-agnostic core with tree-shakeable adapters

The core library has zero runtime dependencies. The React Hook Form and Zod adapters are published as separate sub-path exports (chronologic-validator/react-hook-form, chronologic-validator/zod), so consumers who don't use those frameworks pay zero bundle cost.


React Hook Form Adapter API

useChronologicalValidation(control, fields, options?)

Parameter Type Description
control Control<TFieldValues> The RHF control object from useForm
fields ChronologicalFieldConfig[] Field definitions including the RHF field name
options UseChronologicalValidationOptions All ValidationOptions plus validateOnChange

Returns { result, errors, isValid, validate }.

The hook uses useWatch internally, so validation re-runs automatically whenever any of the watched fields change.


Zod Adapter API

chronologicalRefinement(fields, options?)

Returns a refinement function for use with .superRefine(). Attaches Zod issues directly to the failing field paths, making it fully compatible with zodResolver from @hookform/resolvers.

chronologicalSchema(fields, options?)

Convenience wrapper that creates a complete Zod object schema with all date fields typed as string | null | undefined and chronological validation built in.


Contributing

Contributions, bug reports, and feature requests are welcome. Please open an issue before submitting a PR so we can discuss the change first.

git clone https://github.com/martinschike/chronologic-validator.git
cd chronologic-validator
npm install
npm test

License

MIT © Martins Okafor

About

Framework-agnostic TypeScript library for validating ordered date sequences, with adapters for React Hook Form and Zod

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors