Framework-agnostic TypeScript library for validating ordered date field sequences — with adapters for React Hook Form and Zod.
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.
- ✅ 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_VALUEfromORDER_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
npm install chronologic-validatorThe 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 zodimport { 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'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>
)
}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) // trueOr use the convenience wrapper for simpler cases:
import { chronologicalSchema } from 'chronologic-validator/zod'
const schema = chronologicalSchema([
{ key: 'from', label: 'From' },
{ key: 'to', label: 'To' },
])The core validation function.
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.
| 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. |
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.
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 === trueWhen 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'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'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.
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.
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.
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.
| 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.
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.
Convenience wrapper that creates a complete Zod object schema with all date fields typed as string | null | undefined and chronological validation built in.
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 testMIT © Martins Okafor