Custom Validators
Create reusable validators for your application.
Simple Custom Validatorβ
Use validate() for inline custom validators. The validator receives
(value, control, root):
import { validate } from '@reformer/core/validators';
validation: (path) => {
// Inline custom validator
validate(path.age, (value, _control, _root) => {
if (value < 18) {
return { code: 'mustBeAdult', message: 'Must be 18+' };
}
return null;
});
};
Reusable Validator Factoryβ
Create validator functions that can be reused across your application:
validators/password.ts
/**
* Validates password strength
* Requires: uppercase, lowercase, number, min 8 chars
*/
export function strongPassword() {
return (value: string) => {
if (!value) return null; // Skip if empty (use required() separately)
const errors: Record<string, boolean> = {};
if (!/[A-Z]/.test(value)) {
errors.noUppercase = true;
}
if (!/[a-z]/.test(value)) {
errors.noLowercase = true;
}
if (!/[0-9]/.test(value)) {
errors.noNumber = true;
}
if (value.length < 8) {
errors.tooShort = true;
}
return Object.keys(errors).length ? errors : null;
};
}
// Usage in form
import { required } from '@reformer/core/validators';
import { strongPassword } from './validators/password';
validation: (path) => {
validate(path.password, required());
validate(path.password, strongPassword());
};
Display Specific Errorsβ
{
password.touched && password.errors?.noUppercase && (
<span className="error">Must contain uppercase letter</span>
);
}
{
password.touched && password.errors?.noNumber && (
<span className="error">Must contain a number</span>
);
}
{
password.touched && password.errors?.tooShort && (
<span className="error">Must be at least 8 characters</span>
);
}
Validator with Parametersβ
Create configurable validators:
validators/range.ts
export function range(min: number, max: number) {
return (value: number) => {
if (value == null) return null; // Skip if empty
if (value < min || value > max) {
return {
range: {
min,
max,
actual: value,
},
};
}
return null;
};
}
// Usage
import { range } from './validators/range';
validation: (path) => {
validate(path.quantity, required());
validate(path.quantity, range(1, 100));
};
Error Object with Dataβ
{
quantity.touched && quantity.errors?.range && (
<span className="error">
Value must be between {quantity.errors.range.min} and {quantity.errors.range.max}
</span>
);
}
Validator with Contextβ
Access the entire form during validation:
validators/match-field.ts
/**
* Validates that field matches another field
*/
import type { Validator } from '@reformer/core';
export function matchesPassword<TForm extends { password: string }>(): Validator<TForm, string> {
return (value, _control, root) => {
const password = root.password.value.value;
if (value && password && value !== password) {
return { code: 'passwordMismatch', message: 'Passwords do not match' };
}
return null;
};
}
// Usage
validation: (path) => {
validate(path.password, required());
validate(path.confirmPassword, required());
validate(path.confirmPassword, matchesPassword());
};
Complex Custom Validatorβ
Validator with multiple rules and custom messages:
validators/username.ts
export function username() {
return (value: string) => {
if (!value) return null;
// Length check
if (value.length < 3 || value.length > 20) {
return {
usernameLength: { min: 3, max: 20, actual: value.length },
};
}
// Character check
if (!/^[a-zA-Z0-9_]+$/.test(value)) {
return { usernameInvalidChars: true };
}
// Reserved words
const reserved = ['admin', 'root', 'system'];
if (reserved.includes(value.toLowerCase())) {
return { usernameReserved: true };
}
return null;
};
}
// Usage
validation: (path) => {
validate(path.username, required());
validate(path.username, username());
};
Cross-Field Validationβ
Validate relationships between fields. The third argument root gives access to the entire form proxy.
validation: (path) => {
validate(path.startDate, required());
validate(path.endDate, required());
// Validate end date is after start date
validate(path.endDate, (value, _control, root) => {
const startDate = root.startDate.value.value;
if (value && startDate && new Date(value as string) < new Date(startDate)) {
return { code: 'endBeforeStart', message: 'End date must be after start date' };
}
return null;
});
};
For validation that depends on multiple fields and attaches the error to a specific field,
use validateGroup:
import { validateGroup } from '@reformer/core/validators';
validation: (path) => {
validateGroup(
path,
(scope, _root) => {
const v = scope.getValue();
if (v.startDate && v.endDate && new Date(v.endDate) < new Date(v.startDate)) {
return { code: 'endBeforeStart', message: 'End date must be after start date' };
}
return null;
},
{ targetField: path.endDate }
);
};
Array Item Validationβ
Validate items in dynamic arrays:
interface ContactForm {
name: string;
emails: string[];
}
const form = new GroupNode<ContactForm>({
form: {
name: { value: '' },
emails: [{ value: '' }],
},
validation: (path) => {
validate(path.name, required());
// Validate each email in the array
validate(path.emails.$each, required());
validate(path.emails.$each, email());
},
});
Conditional Validation with Custom Logicβ
Use applyWhen() for conditional custom validators:
import { applyWhen, validate, required } from '@reformer/core/validators';
validation: (path) => {
validate(path.country, required());
// Require tax ID only for US users
applyWhen(
path.country,
(country) => country === 'US',
(path) => {
validate(path.taxId, required());
validate(path.taxId, (value) => {
if (!/^\d{9}$/.test(value as string)) {
return { code: 'invalidTaxId', message: 'Tax ID must be 9 digits' };
}
return null;
});
}
);
};
Async Custom Validatorβ
Check server-side data:
validators/username-availability.ts
export function checkUsernameAvailability() {
return async (value: string) => {
if (!value || value.length < 3) return null;
try {
const response = await fetch(`/api/check-username?username=${encodeURIComponent(value)}`);
const { available } = await response.json();
if (!available) {
return { usernameTaken: true };
}
return null;
} catch (error) {
return { serverError: true };
}
};
}
// Usage
import { validateAsync, validate, required } from '@reformer/core/validators';
validation: (path) => {
validate(path.username, required());
validate(path.username, username());
// Async validation with debounce. Note: pass the async function directly
// (signature: `(value, control, root) => Promise<ValidationError | null>`),
// not a factory invocation.
validateAsync(path.username, checkUsernameAvailability(), {
debounce: 500,
});
};
Practical Examplesβ
Credit Card Validatorβ
validators/credit-card.ts
export function creditCard() {
return (value: string) => {
if (!value) return null;
// Remove spaces and dashes
const cleaned = value.replace(/[\s-]/g, '');
// Check length
if (cleaned.length < 13 || cleaned.length > 19) {
return { invalidCardLength: true };
}
// Luhn algorithm
let sum = 0;
let isEven = false;
for (let i = cleaned.length - 1; i >= 0; i--) {
let digit = parseInt(cleaned[i]);
if (isEven) {
digit *= 2;
if (digit > 9) digit -= 9;
}
sum += digit;
isEven = !isEven;
}
if (sum % 10 !== 0) {
return { invalidCard: true };
}
return null;
};
}
Phone Number Validatorβ
validators/phone.ts
export function phoneNumber(countryCode: string = 'US') {
return (value: string) => {
if (!value) return null;
const patterns = {
US: /^\+?1?\s*\(?([0-9]{3})\)?[-.\s]?([0-9]{3})[-.\s]?([0-9]{4})$/,
UK: /^\+?44\s?[0-9]{10}$/,
RU: /^\+?7\s?\(?\d{3}\)?\s?\d{3}[-\s]?\d{2}[-\s]?\d{2}$/,
};
const pattern = patterns[countryCode];
if (!pattern) {
return { unsupportedCountry: true };
}
if (!pattern.test(value)) {
return { invalidPhone: { country: countryCode } };
}
return null;
};
}
// Usage
validation: (path) => {
validate(path.phone, required());
validate(path.phone, phoneNumber('US'));
};
File Upload Validatorβ
validators/file.ts
interface FileValidatorOptions {
maxSize?: number; // in bytes
allowedTypes?: string[];
}
export function fileValidator(options: FileValidatorOptions = {}) {
return (file: File) => {
if (!file) return null;
const { maxSize = 5 * 1024 * 1024, allowedTypes } = options;
// Check file size
if (file.size > maxSize) {
return {
fileTooLarge: {
maxSize: maxSize / 1024 / 1024,
actual: file.size / 1024 / 1024,
},
};
}
// Check file type
if (allowedTypes && !allowedTypes.includes(file.type)) {
return {
invalidFileType: {
allowed: allowedTypes,
actual: file.type,
},
};
}
return null;
};
}
// Usage
validation: (path) => {
validate(path.avatar, required());
validate(
path.avatar,
fileValidator({
maxSize: 2 * 1024 * 1024, // 2MB
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
})
);
};
Tips for Custom Validatorsβ
1. Return Null for Valid Valuesβ
// β
Good
return null;
// β Bad
return undefined;
return {};
2. Skip Empty Valuesβ
Let required() handle empty values:
export function myValidator() {
return (value: string) => {
if (!value) return null; // Skip empty values
// Validation logic
if (isInvalid(value)) {
return { myError: true };
}
return null;
};
}
3. Use Descriptive Error Keysβ
// β
Good - descriptive
return { passwordTooWeak: true };
return { usernameTaken: true };
// β Bad - generic
return { invalid: true };
return { error: true };
4. Include Useful Error Dataβ
// β
Good - provides context
return {
tooLong: {
max: 100,
actual: value.length,
},
};
// β Bad - no context
return { tooLong: true };
Next Stepsβ
- Async Validation β Server-side validation
- Behaviors β Reactive form logic
- Schema Composition β Share validators across forms