chronologic-validator
v0.1.2
Published
Framework-agnostic TypeScript library for validating ordered date field sequences
Maintainers
Readme
chronologic-validator
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_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
Installation
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 zodQuick 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) // 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' },
])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 === 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'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 testLicense
MIT © Martins Okafor
