Skip to content

Validation

Built-in validators, composition utilities, and the v shorthand object. Includes 18 validator factories, two composition functions, and a derive-don't-configure validation strategy.

Import

ts
import {
  required, email, url, date, phone,
  minLength, maxLength, min, max,
  pattern, number, integer, positive,
  custom, async, matches,
  oneOf, notOneOf, fileSize, fileType,
  compose, when, v,
} from '@ereo/forms'

All validators are also available via the v shorthand:

ts
import { v } from '@ereo/forms'

const validators = {
  email: [v.required(), v.email()],
  age: [v.required(), v.min(18)],
}

Derive-Don't-Configure

The validation engine automatically derives when to validate based on validator types:

Validator TypeDerived TimingRationale
async() validatorschange with debounceProvide feedback while typing
required() onlyblurDon't nag on empty fields while typing
All other validatorsblurDefault -- validate after leaving the field

You can override with validateOn per-field or per-form, but the defaults handle most cases.

Core Validators

required

ts
function required(msg?: string): ValidatorFunction

Fails on null, undefined, '', and empty arrays. Marks the validator with _isRequired = true which drives blur-only timing.

ts
required()                       // "This field is required"
required('Please enter a name')  // custom message

email

ts
function email(msg?: string): ValidatorFunction<string>

Validates email format (/^[^\s@]+@[^\s@]+\.[^\s@]+$/). Skips empty values.

ts
email()                          // "Invalid email address"

url

ts
function url(msg?: string): ValidatorFunction<string>

Uses new URL() to validate. Skips empty values.

date

ts
function date(msg?: string): ValidatorFunction<string>

Validates that the value parses as a valid Date.

phone

ts
function phone(msg?: string): ValidatorFunction<string>

Requires at least 7 digits. Allows +, spaces, hyphens, and parentheses.

Length / Range Validators

minLength

ts
function minLength(n: number, msg?: string): ValidatorFunction<string>
ts
minLength(8)                     // "Must be at least 8 characters"
minLength(3, 'Too short')       // custom message

maxLength

ts
function maxLength(n: number, msg?: string): ValidatorFunction<string>

min

ts
function min(n: number, msg?: string): ValidatorFunction<number>
ts
min(0)    // "Must be at least 0"
min(18)   // "Must be at least 18"

max

ts
function max(n: number, msg?: string): ValidatorFunction<number>

Pattern / Type Validators

pattern

ts
function pattern(regex: RegExp, msg?: string): ValidatorFunction<string>
ts
pattern(/^[A-Z]{2}\d{4}$/, 'Must be format: XX0000')

number

ts
function number(msg?: string): ValidatorFunction

Validates that the value is numeric (uses isNaN(Number(value))).

integer

ts
function integer(msg?: string): ValidatorFunction

Validates that the value is an integer (Number.isInteger).

positive

ts
function positive(msg?: string): ValidatorFunction<number>

Validates that the value is greater than 0.

Custom Validators

custom

ts
function custom<T = unknown>(
  fn: (value: T) => string | undefined,
  msg?: string
): ValidatorFunction<T>

Create a synchronous validator from any function. Return a string to indicate an error, or undefined for success.

ts
custom((value: string) => {
  if (value.includes(' ')) return 'No spaces allowed'
})

async

ts
function async<T = unknown>(
  fn: (value: T) => Promise<string | undefined>,
  opts?: { debounce?: number; message?: string }
): ValidatorFunction<T>

Create an async validator. Marked with _isAsync = true, which causes the ValidationEngine to derive change as the trigger. Default debounce is 300ms.

ts
async(async (username: string) => {
  const res = await fetch(`/api/check-username?q=${username}`)
  const { available } = await res.json()
  return available ? undefined : 'Username is taken'
}, { debounce: 500 })

The validation engine:

  • Debounces calls (configurable, default 300ms for async)
  • Aborts in-flight requests when superseded
  • Provides an AbortSignal via the validation context
  • Tracks validating state per field

Cross-Field Validators

matches

ts
function matches(otherField: string, msg?: string): ValidatorFunction

Validates that the value equals the value of another field. Uses the CrossFieldValidationContext to access other fields. Marked with _crossField = true.

ts
// Confirm password matches password
const validators = {
  password: [required(), minLength(8)],
  confirmPassword: [required(), matches('password', 'Passwords do not match')],
}

The context parameter provides:

ts
interface CrossFieldValidationContext<T> {
  getValue: (path: string) => unknown  // read any field
  getValues: () => T                   // get all values
  signal?: AbortSignal                 // abort signal for async
}

Collection Validators

oneOf

ts
function oneOf<T>(values: T[], msg?: string): ValidatorFunction<T>
ts
oneOf(['admin', 'user', 'guest'], 'Invalid role')

notOneOf

ts
function notOneOf<T>(values: T[], msg?: string): ValidatorFunction<T>
ts
notOneOf(['root', 'admin'], 'Reserved username')

File Validators

fileSize

ts
function fileSize(maxBytes: number, msg?: string): ValidatorFunction
ts
fileSize(5 * 1024 * 1024)  // "File must be less than 5120KB"

fileType

ts
function fileType(types: string[], msg?: string): ValidatorFunction
ts
fileType(['image/png', 'image/jpeg'], 'Must be PNG or JPEG')

Composition

compose

ts
function compose<T = unknown>(
  ...rules: ValidatorFunction<T>[]
): ValidatorFunction<T>

Chain multiple validators into one. Runs validators in order and stops on the first error. Inherits _isAsync, _isRequired, _crossField, and _debounce properties from composed validators.

ts
const passwordValidator = compose(
  required(),
  minLength(8),
  pattern(/[A-Z]/, 'Must contain uppercase'),
  pattern(/[0-9]/, 'Must contain a number'),
)

when

ts
function when<T = unknown>(
  condition: (value: T, context?: CrossFieldValidationContext) => boolean,
  rule: ValidatorFunction<T>
): ValidatorFunction<T>

Conditionally apply a validator. The rule only runs if condition returns true.

ts
when(
  (_, ctx) => ctx?.getValue('accountType') === 'business',
  required('Company name is required for business accounts')
)

The v Shorthand

The v object re-exports all validators and composition functions as a single namespace:

ts
import { v } from '@ereo/forms'

v.required, v.email, v.url, v.date, v.phone,
v.minLength, v.maxLength, v.min, v.max,
v.pattern, v.number, v.integer, v.positive,
v.custom, v.async, v.matches,
v.oneOf, v.notOneOf, v.fileSize, v.fileType,
v.compose, v.when

ValidatorFunction Interface

ts
interface ValidatorFunction<T = unknown> {
  (value: T, context?: CrossFieldValidationContext<any>):
    | string
    | undefined
    | Promise<string | undefined>;
  _isAsync?: boolean;
  _isRequired?: boolean;
  _crossField?: boolean;
  _debounce?: number;
}

Validators return undefined for valid, or an error string. The metadata flags are used by the ValidationEngine to derive behavior.

ValidationEngine Internals

The ValidationEngine determines when to validate each field using a derive-don't-configure strategy based on validator metadata:

ConfigurationTiming
Default (sync validators)blur
required() onlyblur
Contains async()change + debounce (default 300ms)
validateOn: 'change' (explicit)change
validateOn: 'submit' (explicit)submit only

When validation triggers:

  • change -- onFieldChange() runs the validators. Async validators use debounce (configurable via _debounce).
  • blur -- onFieldBlur() runs the validators immediately. Cancels any pending debounced validation.
  • submit -- validators only run when validateAll() is called during form submission.

Async validations use AbortController and per-field generation tracking to cancel stale results when a newer validation starts.

Released under the MIT License.