validant
v0.2.0
Published
Validant is a TypeScript-first validation library for real-world, dynamic rules — no DSLs, just types and functions.
Maintainers
Readme
Validant
Validant is a TypeScript-first validation library for real-world, dynamic rules — no DSLs, just types and functions.
// Define your model
interface User {
name: string;
email: string;
age: number;
}
// Create validation rules
const userRule: ValidationRule<User> = {
name: [required("Name is required")],
email: [required(), emailAddress("Invalid email")],
age: [required(), minNumber(18, "Must be 18+")]
};
// Validate
const validator = new Validator();
const result = validator.validate(user, userRule);Table of Contents
- ✨ Why Validant?
- 📊 Benchmark Results
- 🧩 When validation is complex and not just "required()"
- 🏁 Getting Started
- 🛡️ Type Safe
- ✅ Type Freedom
- 🛠️ Validation
- 🔧 Custom Validation
- 🧮 Array Validation
- ⚠️ Error Structure Breakdown
- 🧬 Validation Context Awareness: Property, Root Object, and Arrays
- Examples
- 🧩 Validation Rule Composition
- 📚 API Reference
- Built-in Rules
- 🔄 Flat Error Structure for UI/API
✨ Why Validant?
- 🔄 TYPE-FIRST, NOT SCHEMA-FIRST = LOOSE COUPLING: Unlike other libraries that generate types from schemas, Validant starts from your own types — allowing you to decouple your app from any validation library, including this one.
- 🧠 No DSLs. No special syntax. Just plain functions.
- 🧩 Composable: Easily combine validations and reuse them across your codebase.
- 🪶 Zero dependencies. Minimal API. Maximum control.
- 🧪 Made for TypeScript first: Validant is written in and only tested with TypeScript. It's built for modern TypeScript-first projects. It might work in JavaScript — but it's never been tested there.
- ✅ Deep, fine-grained validation on individual fields — sync or async, arrays, nested objects, also support Validate per Field
🧩 When validation is complex and not just "required()"
import {
ValidationRule,
AsyncValidationRule,
Validator,
AsyncValidator,
required,
minNumber,
maxNumber,
emailAddress,
isString,
isNumber,
elementOf,
arrayMinLen,
arrayMaxLen,
ValidateFunc,
AsyncValidateFunc,
RuleViolation,
ValidationResult
} from '../../index';
// =============================================================================
// DOMAIN MODELS
// =============================================================================
interface Address {
street: string;
city: string;
state: string;
zipCode: string;
country: string;
}
interface Person {
firstName: string;
lastName: string;
dateOfBirth: Date;
ssn: string; // Social Security Number
address: Address;
phone: string;
email: string;
}
interface PolicyHolder extends Person {
policyNumber: string;
policyStartDate: Date;
policyEndDate: Date;
premiumAmount: number;
coverageType: 'BASIC' | 'PREMIUM' | 'COMPREHENSIVE';
riskScore: number; // 1-100, calculated by underwriting
}
interface Vehicle {
vin: string; // Vehicle Identification Number
make: string;
model: string;
year: number;
mileage: number;
value: number; // Current market value
primaryUse: 'PERSONAL' | 'COMMERCIAL' | 'BUSINESS';
safetyRating: number; // 1-5 stars
antiTheftDevices: string[];
}
interface Incident {
incidentDate: Date;
incidentTime: string; // HH:MM format
location: Address;
description: string;
policeReportNumber?: string;
weatherConditions: 'CLEAR' | 'RAIN' | 'SNOW' | 'FOG' | 'ICE' | 'SEVERE';
roadConditions: 'DRY' | 'WET' | 'ICY' | 'CONSTRUCTION' | 'POOR_VISIBILITY';
atFaultParties: string[]; // Can be multiple parties
witnessCount: number;
}
interface Damage {
component: string; // e.g., "Front Bumper", "Engine", "Windshield"
severity: 'MINOR' | 'MODERATE' | 'SEVERE' | 'TOTAL_LOSS';
estimatedCost: number;
repairShop?: string;
partsRequired: string[];
laborHours: number;
isPreExistingDamage: boolean;
}
interface MedicalClaim {
injuredParty: Person;
injuryType: string;
severity: 'MINOR' | 'MODERATE' | 'SEVERE' | 'CRITICAL';
treatmentFacility: string;
doctorName: string;
estimatedTreatmentCost: number;
isPreExistingCondition: boolean;
requiresSpecialistCare: boolean;
}
interface InsuranceClaim {
claimNumber: string;
claimType: 'AUTO_ACCIDENT' | 'THEFT' | 'VANDALISM' | 'NATURAL_DISASTER' | 'COMPREHENSIVE';
policyHolder: PolicyHolder;
claimant: Person; // Person filing the claim (might be different from policy holder)
vehicle: Vehicle;
incident: Incident;
damages: Damage[];
medicalClaims: MedicalClaim[];
claimAmount: number; // Total claimed amount
supportingDocuments: string[]; // Document IDs
attorneyInvolved: boolean;
attorneyDetails?: {
name: string;
barNumber: string;
firm: string;
phone: string;
};
priorClaims: number; // Number of claims in last 5 years
submissionDate: Date;
adjusterId?: string;
}
// =============================================================================
// MOCK EXTERNAL SERVICES
// =============================================================================
interface ExternalServices {
validateSSN(ssn: string): Promise<boolean>;
validateVIN(vin: string): Promise<{ isValid: boolean; vehicleInfo?: any }>;
validatePolicyStatus(policyNumber: string): Promise<{ isActive: boolean; hasOutstandingPremiums: boolean }>;
}
// Mock implementation
const externalServices: ExternalServices = {
async validateSSN(ssn: string): Promise<boolean> {
await new Promise(resolve => setTimeout(resolve, 10));
return /^\d{3}-\d{2}-\d{4}$/.test(ssn);
},
async validateVIN(vin: string): Promise<{ isValid: boolean; vehicleInfo?: any }> {
await new Promise(resolve => setTimeout(resolve, 15));
const isValid = /^[A-HJ-NPR-Z0-9]{17}$/.test(vin);
return {
isValid,
vehicleInfo: isValid ? { decoded: true } : undefined
};
},
async validatePolicyStatus(policyNumber: string): Promise<{ isActive: boolean; hasOutstandingPremiums: boolean }> {
await new Promise(resolve => setTimeout(resolve, 10));
return {
isActive: !policyNumber.includes('EXPIRED'),
hasOutstandingPremiums: policyNumber.includes('OVERDUE')
};
}
};
// =============================================================================
// BUSINESS RULE VALIDATORS
// =============================================================================
function validateSSNFormat(): AsyncValidateFunc<string, any> {
return async function (ssn: string) {
if (!ssn) return undefined;
const isValidFormat = await externalServices.validateSSN(ssn);
if (!isValidFormat) {
return {
ruleName: 'validateSSNFormat',
attemptedValue: ssn,
errorMessage: 'Invalid Social Security Number format or number does not exist.'
};
}
};
}
function validateVINNumber(): AsyncValidateFunc<string, InsuranceClaim> {
return async function (vin: string, claim: InsuranceClaim) {
if (!vin) return undefined;
const result = await externalServices.validateVIN(vin);
if (!result.isValid) {
return {
ruleName: 'validateVINNumber',
attemptedValue: vin,
errorMessage: 'Invalid Vehicle Identification Number. Please verify the VIN.'
};
}
};
}
function validatePolicyActive(): AsyncValidateFunc<string, InsuranceClaim> {
return async function (policyNumber: string, claim: InsuranceClaim) {
if (!policyNumber) return undefined;
const status = await externalServices.validatePolicyStatus(policyNumber);
if (!status.isActive) {
return {
ruleName: 'validatePolicyActive',
attemptedValue: policyNumber,
errorMessage: 'Policy is not active. Claims cannot be processed for inactive policies.'
};
}
if (status.hasOutstandingPremiums) {
return {
ruleName: 'validatePolicyActive',
attemptedValue: policyNumber,
errorMessage: 'Policy has outstanding premiums. Please resolve payment issues before filing a claim.'
};
}
};
}
function validateIncidentDate(): ValidateFunc<Date, InsuranceClaim> {
return function (incidentDate: Date, claim: InsuranceClaim) {
if (!incidentDate) return undefined;
const now = new Date();
const policyStart = claim.policyHolder.policyStartDate;
const policyEnd = claim.policyHolder.policyEndDate;
// Can't be in the future
if (incidentDate > now) {
return {
ruleName: 'validateIncidentDate',
attemptedValue: incidentDate,
errorMessage: 'Incident date cannot be in the future.'
};
}
// Must be within policy period
if (incidentDate < policyStart || incidentDate > policyEnd) {
return {
ruleName: 'validateIncidentDate',
attemptedValue: incidentDate,
errorMessage: `Incident must have occurred during the policy period (${policyStart.toDateString()} - ${policyEnd.toDateString()}).`
};
}
// Claims must be filed within 30 days of incident (business rule)
const daysSinceIncident = Math.floor((now.getTime() - incidentDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysSinceIncident > 30) {
return {
ruleName: 'validateIncidentDate',
attemptedValue: incidentDate,
errorMessage: 'Claims must be filed within 30 days of the incident. Late filing requires special approval.'
};
}
};
}
// =============================================================================
// COMPREHENSIVE VALIDATION RULES
// =============================================================================
const insuranceClaimValidationRules: AsyncValidationRule<InsuranceClaim> = {
claimNumber: [
required('Claim number is required.'),
isString('Claim number must be a string.'),
function (claimNumber: string) {
if (!/^INS-\d{4}-\d{6}$/.test(claimNumber)) {
return {
ruleName: 'claimNumberFormat',
attemptedValue: claimNumber,
errorMessage: 'Claim number must follow format: INS-YEAR-XXXXXX (e.g., INS-2024-000001)'
};
}
}
],
claimType: [
required('Claim type is required.'),
elementOf(['AUTO_ACCIDENT', 'THEFT', 'VANDALISM', 'NATURAL_DISASTER', 'COMPREHENSIVE'], 'Invalid claim type.')
],
policyHolder: {
firstName: [required('Policy holder first name is required.'), isString()],
lastName: [required('Policy holder last name is required.'), isString()],
dateOfBirth: [
required('Date of birth is required.'),
function (dob: Date, claim: InsuranceClaim) {
const age = Math.floor((Date.now() - dob.getTime()) / (1000 * 60 * 60 * 24 * 365));
if (age < 16) {
return {
ruleName: 'minimumAge',
attemptedValue: dob,
errorMessage: 'Policy holder must be at least 16 years old.'
};
}
if (age > 100) {
return {
ruleName: 'maximumAge',
attemptedValue: dob,
errorMessage: 'Please verify date of birth. Age appears to be over 100 years.'
};
}
}
],
ssn: [
required('Social Security Number is required.'),
validateSSNFormat()
],
policyNumber: [
required('Policy number is required.'),
validatePolicyActive()
],
email: [required(), emailAddress()],
premiumAmount: [
required(),
minNumber(100, 'Minimum premium amount is $100.'),
maxNumber(50000, 'Premium amount seems unusually high. Please verify.')
],
coverageType: [
required(),
elementOf(['BASIC', 'PREMIUM', 'COMPREHENSIVE'], 'Invalid coverage type.')
],
riskScore: [
required(),
minNumber(1, 'Risk score must be between 1 and 100.'),
maxNumber(100, 'Risk score must be between 1 and 100.')
],
address: {
street: [required()],
city: [required()],
state: [required()],
zipCode: [
required(),
function (zipCode: string) {
if (!/^\d{5}(-\d{4})?$/.test(zipCode)) {
return {
ruleName: 'zipCodeFormat',
attemptedValue: zipCode,
errorMessage: 'ZIP code must be in format: 12345 or 12345-6789'
};
}
}
],
country: [required()]
}
},
claimant: {
firstName: [required()],
lastName: [required()],
email: [required(), emailAddress()],
ssn: [validateSSNFormat()],
address: {
street: [required()],
city: [required()],
state: [required()],
zipCode: [required()],
country: [required()]
}
},
vehicle: {
vin: [
required('Vehicle VIN is required.'),
validateVINNumber()
],
make: [required()],
model: [required()],
year: [
required(),
minNumber(1990, 'Vehicles older than 1990 require special underwriting.'),
maxNumber(new Date().getFullYear() + 1, 'Vehicle year cannot be in the future.')
],
mileage: [
required(),
minNumber(0, 'Mileage cannot be negative.'),
function (mileage: number, claim: InsuranceClaim) {
const vehicleAge = new Date().getFullYear() - claim.vehicle.year;
const expectedMaxMileage = vehicleAge * 15000;
if (mileage > expectedMaxMileage * 1.5) {
return {
ruleName: 'mileageValidation',
attemptedValue: mileage,
errorMessage: `Mileage (${mileage.toLocaleString()}) appears unusually high for a ${claim.vehicle.year} vehicle.`
};
}
}
],
value: [
required(),
minNumber(1000, 'Vehicle value must be at least $1,000 to be eligible for coverage.')
],
primaryUse: [
required(),
elementOf(['PERSONAL', 'COMMERCIAL', 'BUSINESS'], 'Invalid primary use type.')
],
safetyRating: [
required(),
minNumber(1, 'Safety rating must be between 1 and 5.'),
maxNumber(5, 'Safety rating must be between 1 and 5.')
]
},
incident: {
incidentDate: [
required('Incident date is required.'),
validateIncidentDate()
],
incidentTime: [
required(),
function (time: string) {
if (!/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/.test(time)) {
return {
ruleName: 'timeFormat',
attemptedValue: time,
errorMessage: 'Time must be in HH:MM format (24-hour).'
};
}
}
],
description: [
required(),
function (description: string) {
if (description.length < 50) {
return {
ruleName: 'descriptionLength',
attemptedValue: description,
errorMessage: 'Incident description must be at least 50 characters long.'
};
}
}
],
weatherConditions: [
required(),
elementOf(['CLEAR', 'RAIN', 'SNOW', 'FOG', 'ICE', 'SEVERE'], 'Invalid weather condition.')
],
roadConditions: [
required(),
elementOf(['DRY', 'WET', 'ICY', 'CONSTRUCTION', 'POOR_VISIBILITY'], 'Invalid road condition.')
],
witnessCount: [
required(),
minNumber(0, 'Witness count cannot be negative.'),
maxNumber(20, 'Witness count seems unusually high. Please verify.')
],
location: {
street: [required()],
city: [required()],
state: [required()],
zipCode: [required()],
country: [required()]
}
},
damages: {
arrayRules: [
arrayMinLen(1, 'At least one damage entry is required.')
],
arrayElementRule: {
component: [required()],
severity: [
required(),
elementOf(['MINOR', 'MODERATE', 'SEVERE', 'TOTAL_LOSS'], 'Invalid damage severity.')
],
estimatedCost: [
required(),
minNumber(1, 'Damage cost must be greater than $0.'),
maxNumber(200000, 'Damage cost exceeds maximum limit.')
],
laborHours: [
required(),
minNumber(0, 'Labor hours cannot be negative.'),
maxNumber(500, 'Labor hours seem excessive.')
]
}
},
claimAmount: [
required('Claim amount is required.'),
minNumber(1, 'Claim amount must be greater than $0.')
],
supportingDocuments: {
arrayRules: [
arrayMinLen(1, 'At least one supporting document is required.')
]
},
priorClaims: [
required(),
minNumber(0, 'Prior claims count cannot be negative.'),
function (priorClaims: number, claim: InsuranceClaim) {
if (priorClaims >= 5) {
return {
ruleName: 'priorClaimsLimit',
attemptedValue: priorClaims,
errorMessage: 'Policy holders with 5+ prior claims require executive approval.'
};
}
}
],
submissionDate: [
required(),
function (submissionDate: Date) {
const now = new Date();
if (submissionDate > now) {
return {
ruleName: 'submissionDateFuture',
attemptedValue: submissionDate,
errorMessage: 'Submission date cannot be in the future.'
};
}
}
]
};🏁 Getting Started
📦 Installation
npm install validant
# or
yarn add validant🛠️ Validation Rule, Not Schema
Your model is your source of truth.
If you already have a model (and you should), Validant wraps validations around it — not the other way around:
class Account {
name: string;
age: number;
email: string;
}Then you simply declare your validation rule dan validate:
import { minNumber, required, emailAddress, ValidationRule } from "validant";
const validationRule: ValidationRule<Account> = {
name: [required("Account name is required.")],
age: [required(), minNumber(17, "Should be at least 17 years old.")],
email: [required(), emailAddress("Invalid email address")],
};
// validate
const validator = new Validator();
const validationResult = validator.validate(account, validationRule);If your model already defines the structure, why repeat it with something like name: string() or username: z.string()?
It works with literal object as well:
import { minNumber, required, emailAddress, ValidationRule } from "validant";
const account = {
name: "",
age: 0,
email: "",
};
const validationRule: ValidationRule<typeof account> = {
name: [required("Account name is required.")],
age: [required(), minNumber(17, "Should be at least 17 years old.")],
email: [required(), emailAddress("Invalid email address")],
};✅ IntelliSense That Just Works
Here's how validation rules align seamlessly with IntelliSense:
Because Validant uses your existing model, the validation rule knows your properties — their names and types — without extra declarations.
No inference hacks. No schema dance. Just proper TypeScript support, right out of the box.
🛡️ Type Safe
🔒 Safe at Compile Time
No mismatched property names. No type mismatches. TypeScript will catch it — instantly.
For example, Account doesn't have a creditCardNumber — and TypeScript will let you know right away.
This is especially useful when your model changes: the validation rule will break where it should, making it easy to stay in sync.
Rules are type-safe too — they know exactly what type they're validating, and you can build your own custom rules with full type awareness.
Even with inferred literal objects, type safety still holds:
🔒 Safe at Run Time
Need runtime guarantees? Validant has built-in rules for that too:
import {
emailAddress,
isString,
minNumber,
required,
ValidationRule,
} from "validant";
const validationRule: ValidationRule<Account> = {
name: [isString("Name should be string")],
age: [required()],
};or you can do it your own way:
import {
emailAddress,
isString,
minNumber,
required,
ValidationRule,
} from "validant";
const validationRule: ValidationRule<Account> = {
name: [
(name, account) => {
const isString = typeof name === "string"; // check yourself, either return error or throw
if (!isString) {
return {
ruleName: "",
attemptedValue: name,
errorMessage: "Please enter the name with string",
};
}
},
],
age: [required()],
};You are not enforced to define all properties, you can set partially set validation for age only:
import { minNumber, required, emailAddress, ValidationRule } from "validant";
const validationRule: ValidationRule<Account> = {
age: [required(), minNumber(17, "Should be at least 17 years old.")],
};✅ Type Freedom
Validant works seamlessly with any kind of TypeScript structure — whether you're using interface, type, class, or even inferring types from objects.
Using a class
import { minNumber, required, emailAddress, ValidationRule } from "validant";
class Account {
name: string;
age: number;
email: string;
}
const validationRule: ValidationRule<Account> = {
name: [required("Account name is required.")],
age: [required(), minNumber(17, "Should be at least 17 years old.")],
email: [required(), emailAddress("Invalid email address")],
};Using a interface
import { minNumber, required, emailAddress, ValidationRule } from "validant";
interface Account {
name: string;
age: number;
email: string;
}
const validationRule: ValidationRule<Account> = {
name: [required("Account name is required.")],
age: [required(), minNumber(17, "Should be at least 17 years old.")],
email: [required(), emailAddress("Invalid email address")],
};Using a type
import { minNumber, required, emailAddress, ValidationRule } from "validant";
type Account = {
name: string;
age: number;
email: string;
};
const validationRule: ValidationRule<Account> = {
name: [required("Account name is required.")],
age: [required(), minNumber(17, "Should be at least 17 years old.")],
email: [required(), emailAddress("Invalid email address")],
};Inferring from an object
import { minNumber, required, emailAddress, ValidationRule } from "validant";
type Account = {
name: string;
age: number;
email: string;
};
const account: Account = {
name: "",
age: 0,
email: "",
};
const validationRule: ValidationRule<typeof account> = {
name: [required("Account name is required.")],
age: [required(), minNumber(17, "Should be at least 17 years old.")],
email: [required(), emailAddress("Invalid email address")],
};Use what fits your project best — Validant adapts to your TypeScript style.
🛠️ Validation
Validant supports both synchronous and asynchronous validation.
⚡Sync Validation
Object Level Validation
Validation rules are represented as:
ValidationRule<T, TRoot extends object = T>
import {
Validator,
ValidationRule,
Validator,
required,
minNumber,
emailAddress,
} from "validant";
// Given your data model:
interface Account {
name: string;
age: number;
email: string;
}
// validation rules
const validationRule: ValidationRule<Account> = {
name: [required("Account name is required.")],
age: [required(), minNumber(17, "Should be at least 17 years old.")],
email: [required(), emailAddress("Invalid email address")],
};Instantiate validator and validate
const account: Account = {
name: "",
age: 0,
email: "",
};
// validate
const validator = new Validator();
const validationResult = validator.validate(account, validationRule);The result looks like this:
{
message: "Validation failed. Please check and fix the errors to continue.",
isValid: false,
errors: {
name: [
{
attemptedValue: "",
errorMessage: "Account name is required.",
ruleName: "required"
}
],
age: [
{
attemptedValue: 0,
errorMessage: "Should be at least 17 years old.",
ruleName: "minNumber"
}
],
email: [
{
errorMessage: "This field is required.",
attemptedValue: "",
ruleName: "required"
},
{
errorMessage: "Invalid email address",
attemptedValue: "",
ruleName: "emailAddress"
}
]
},
};Field Level Validation
You can validate a specific field of an object easily by calling validator.validateField(fieldName, object).
const account: Account = {
name: "",
age: 0,
email: "",
};
// Create a validator with your validation rules
const validator = new Validator();
// Validate the "name" field of the account object
const validationResult = validator.validateField(account, "name", validationRule);The result will be an object like this:
{
isValid: false,
fieldName: "name",
errors: [
{
attemptedValue: "",
errorMessage: "This field is required.",
ruleName: "required"
}
]
}This lets you perform precise, field-level validation with clear error feedback.
🌐 Async Validation
Async Object Level Validation
If you want to use an async function as your rule function, you need to define your rule with: AsyncValidationRule.
Async rules are represented as:
AsyncValidationRule<T, TRoot extends Object = T>
import {
AsyncValidationRule,
AsyncValidator,
required,
minNumber,
emailAddress,
} from "validant";
// Given your data model:
interface Account {
name: string;
age: number;
email: string;
}
// validation rules
const validationRule: AsyncValidationRule<Account> = {
name: [required("Account name is required.")],
age: [required(), minNumber(17, "Should be at least 17 years old.")],
email: [
required(),
emailAddress("Invalid email address"),
// AsyncValidationRule allows you to accept async function rule, while ValidationRule not.
async (email: string) => {
/* ...check api or database */
},
],
};Instantiate the AsyncValidator and validate
const account: Account = {
name: "",
age: 0,
email: "",
};
// validate
const validator = new AsyncValidator();
const validationResult = await validator.validateAsync(account, validationRule);You'll get the same structured result:
// The validationResult above is equivalent to the following:
{
message: "Validation failed. Please check and fix the errors to continue.",
isValid: false,
errors: {
name: [
{
attemptedValue: "",
errorMessage: "Account name is required.",
ruleName: "required"
}
],
age: [
{
attemptedValue: 0,
errorMessage: "Should be at least 17 years old.",
ruleName: "minNumber"
}
],
email: [
{
errorMessage: "This field is required.",
attemptedValue: "",
ruleName: "required"
},
{
errorMessage: "Invalid email address",
attemptedValue: "",
ruleName: "emailAddress"
}
]
},
};Async Field Level Validation
You can validate a specific field of an object easily by calling validator.validateFieldAsync(fieldName, object).
const account: Account = {
name: "",
age: 0,
email: "",
};
// Create a validator with your validation rules
const validator = new AsyncValidator();
// Validate the "email" field of the account object
const validationResult = await validator.validateFieldAsync(account, "email", validationRule);The result will be an object like this:
{
isValid: false,
fieldName: "email",
errors: [
{
errorMessage: "This field is required.",
attemptedValue: "",
ruleName: "required"
},
{
errorMessage: "Invalid email address",
attemptedValue: "",
ruleName: "emailAddress"
}
]
}This lets you perform precise, async field-level validation with clear error feedback.
✅ Intuitive Error Structure
As you can see the above validationResult.errors mirrors the shape of your original object — field by field. There's no guesswork, no opaque path strings ("user[0].email"), and no nested issues[] array to parse.
Validant gives you direct, predictable access to error messages using the same property keys as your data model.
You already know how to access your errors:
if (validationResult.errors.email) {
console.error(validationResult.errors.email.join(" "));
}- ❌ No weird formats.
- ✅ The errors = your original object properties
🔧 Custom Validation
Validant provides complete control through custom validation functions with strict type signatures.
Validation Function Signature
/**
* The return type if when validation failed
*/
export interface RuleViolation {
ruleName: string;
attemptedValue: any;
errorMessage: string;
}
/**
* Rule function signature
* @template TValue - Type of the property being validated
* @template TRoot - Type of the root object
*/
export type ValidateFunc<TValue, TRoot extends Object> = (
value: TValue,
root: TRoot
) => RuleViolation | undefined;Key Advantages
- ✅ The root provides full access to the object being validated.
- ✅ All custom rules are context-aware — validating against sibling fields is easy.
- ❌ No hacks or workarounds needed.
🧩 Inline Custom Rule Example
You can define your custom validation both inline or as a separated function.
interface LoginRequest {
userName: string;
password: string;
}
const loginRule: ValidationRule<LoginRequest> = {
userName: [
function (username, loginRequest) {
if (!username) {
return {
ruleName: "custom",
attemptedValue: username,
errorMessage: "Please enter username.",
};
}
},
function (username, loginRequest) {
if (username.toLocaleLowerCase().includes("admin")) {
return {
ruleName: "custom",
attemptedValue: username,
errorMessage: "Admin is not allowed to login.",
};
}
},
],
password: [
function (password, loginRequest) {
if (!password) {
return {
ruleName: "any",
attemptedValue: password,
errorMessage: "Please enter password.",
};
}
},
],
};Validation Execution
const loginRequest: LoginRequest = {
userName: "",
password: "",
};
const validator = new Validator();
const result = validator.validate(loginRequest, loginRule);Result Structure
{
message: "Validation failed. Please check and fix the errors to continue.",
isValid: false,
errors: {
userName: [
{
errorMessage: "Please enter username.",
attemptedValue: "",
ruleName: "custom"
}
],
password: [
{
errorMessage: "Please enter password.",
attemptedValue: "",
ruleName: "custom"
}
],
},
};🧩 Composable Rule
Not a fan of bulky inline validations? If you feel the above inline validation is fat, lets turn them into reusable function instead with a meaningful function name:
interface LoginRequest {
userName: string;
password: string;
}
function requiredUserNameRule(): ValidateFunc<string, LoginRequest> {
return function (username, loginRequest) {
if (!username) {
return {
ruleName: requiredUserNameRule.name,
attemptedValue: username,
errorMessage: "Please enter username.",
};
}
};
}
function adminShouldBeBlocked(): ValidateFunc<string, LoginRequest> {
return function (username, loginRequest) {
if (username.toLocaleLowerCase().includes("admin")) {
return {
ruleName: adminShouldBeBlocked.name,
attemptedValue: username,
errorMessage: "Admin is not allowed to login.",
};
}
};
}
function requiredPasswordRule(): ValidateFunc<string, LoginRequest> {
return function (password, loginRequest) {
if (!password) {
return {
ruleName: requiredPasswordRule.name,
attemptedValue: password,
errorMessage: "Please enter password.",
};
}
};
}
// Much simpler. you can put above custom rules into its own files, its your choice
const loginRule: ValidationRule<LoginRequest> = {
userName: [requiredUserNameRule(), adminShouldBeBlocked()],
password: [requiredPasswordRule()],
};🔓 Loose Coupling
The adminShouldBeBlocked function represents a domain-specific business validation. It's too valuable to be tightly coupled to any particular validation library. That logic belongs to your domain, not to infrastructure.
The type ValidateFunc<string, LoginRequest> is simply a helper — it gives you compile-time type safety and ensures your rule is compatible with the validation engine. But it's not mandatory to explicitly annotate every rule with it.
For example, this will still work seamlessly:
// The ValidateFunc<string, LoginRequest> removed
function adminShouldBeBlocked() {
return function (username, loginRequest) {
if (username.toLocaleLowerCase().includes("admin")) {
return {
ruleName: adminShouldBeBlocked.name,
attemptedValue: username,
errorMessage: "Admin is not allowed to login.",
};
}
};
}As long as the function conforms to the expected shape, it will integrate with the validation system automatically — making your domain logic loosely coupled, portable, and testable without dragging in validation dependencies.
This approach encourages separation of concerns:
- Keep domain rules in domain layers.
- Easy to test.
- Keep validation orchestration in the validation layer.
- Compose them freely using meaningful, reusable functions.
Even if you change your framework in the future, the adminShouldBeBlocked remains highly reusable. An adapter function is all it takes to integrate it elsewhere.
🧮 Array Validation
Validating arrays in Validant is simple yet powerful. You can apply rules both to the array itself (e.g. length checks) and to each individual item in the array.
Validation Rule Example
interface OrderItem {
productId: number;
quantity: number;
}
interface Order {
id: string;
orderItems: OrderItem[];
}
const orderRule: ValidationRule<Order> = {
orderItems: {
arrayRules: [arrayMinLen(1)], // Array-level rules
arrayElementRule: {
// Item-level rules
productId: [required()],
quantity: [
minNumber(1, "Min qty is 1."),
maxNumber(5, "Max qty is 5."),
],
},
},
};Validation Scenarios
1. Empty Array Validation
const emptyOrder: Order = {
id: "1",
orderItems: [], // Fails arrayMinLen
};
const validator = new Validator();
const result = validator.validate(emptyOrder, orderRule);The above validation results error structure:
{
message: "Validation failed. Please check and fix the errors to continue.",
isValid: false,
errors: {
orderItems: {
arrayErrors: [
{
errorMessage: "The minimum length for this field is 1.",
attemptedValue: [],
ruleName: "arrayMinLen"
}
]
}
}
}2. Invalid Items Validation
const invalidItemsOrder: Order = {
id: "1",
orderItems: [
{
productId: 1, // Valid
quantity: 0, // Fails minNumber
},
],
};The above validation results the following error structure
{
message: "Validation failed. Please check and fix the errors to continue.",
isValid: false,
errors: {
orderItems: {
arrayElementErrors: [
{
index: 0,
attemptedValue: {
productId: 1,
quantity: 0
},
errors: {
quantity: [
{
errorMessage: "Min qty is 1.",
attemptedValue: 0,
ruleName: "minNumber"
}
]
}
}
]
}
}
}Key Features
✅ Dual-Level Validation
- arrayRules: Validate the array itself (length, required, etc.). Array level error is represented in the errors property
- arrayElementRule: Validate each item's structure. Array item error is represented in the arrayElementErrors property.
✅ Precise Error Reporting
- Clear distinction between array-level and item-level errors
- Includes index references for invalid items
- Errors are structured by index, so you know which item failed and why.
✅ Type-Safe Nesting
- Item rules maintain full type checking against the item type
- Works with any depth of nested arrays
- Dont worry if your property names or data structure change, TypeScript (and your editor) will catch it instantly
❌ No confusing string paths like "orderItems[0].quantity"
- Good luck debugging when property names change, or when you're trying to trace validation errors in deeply nested structures.
- Binding errors back to your UI? Prepare for brittle code and guesswork.
⚠️ Error Structure Breakdown
The error is separated into 2 models
- Object Error
- Array error
🧱 {} Object Error
Object error is represented by:
export type ErrorOf<T extends Object> = {
[key in keyof T]?: T[key] extends Date
? RuleViolation[]
: T[key] extends PossiblyUndefined<Array<any>>
? ErrorOfArray<T[key]>
: T[key] extends PossiblyUndefined<object>
? ErrorOf<T[key]>
: RuleViolation[];
};where RuleViolation
export interface RuleViolation {
ruleName: string;
attemptedValue: any;
errorMessage: string;
}An ErrorOf<T> maps each field in an object to a array of RuleViolation object. Here's an example to make it clear:
Given the model:
interface Address {
street: string;
cityId: number;
}then the error of Address or ErrorOf<Address> will be:
interface Address {
street: RuleViolation[];
cityId: RuleViolation[];
}So the possible ouput of the address error is:
const addressError = {
street: [
{
errorMessage: "required.",
attemptedValue: "",
ruleName: "required"
},
{
errorMessage: "min length is 3 chars.",
attemptedValue: "",
ruleName: "arrayMinLen"
}
],
cityId: [
{
errorMessage: "invalid city",
attemptedValue: 0,
ruleName: "isValidCityId"
},
{
errorMessage: "must be a number",
attemptedValue: "1",
ruleName: "isNumber"
}
];
}🧱 [] Array Error
Array validation errors differ slightly from object errors. Instead of mapping directly to the array model, they provide context around:
- Errors for the array itself
- Errors for each item in the array
Here's the structure:
export type ErrorOfArray<TArray> = {
arrayErrors?: string[]; // the error of array itself or array level errors
arrayElementErrors?: IndexedErrorOf<ArrayElementType<TArray>>[]; // array item errors representation
};Each item error is described using IndexedErrorOf:
export type IndexedErrorOf<T extends Object> = {
index: number; // the array item index being validated
errors: ErrorOf<T>; // note this error still shape the original model.
attemptedValue: T | null | undefined; // this is the array item that is being validated.
};Example:
Given model:
interface Order {
id: string;
orderItems: OrderItem[];
}
interface OrderItem {
productId: number;
quantity: number;
}The validation error might look like:
{
id: ["required."]
orderItems: {
arrayErrors:["The minimum order items is 10 items, please add 9 more."], // array level errors
arrayElementErrors: [ // array items error
{
index: 0,
attemptedValue: { // the object reference being validated
productId: 1,
quantity: 0,
},
errors: { // product error
productId: [
{
errorMessage: "invalid product id.",
attemptedValue: "1",
ruleName: "isNumber"
}
],
quantity: [
{
errorMessage: "Minimum quantity is 1",
attemptedValue: "1",
ruleName: "isNumber"
}
],
},
},
];
}
}🛡️ Type-Safe Error
Take this example from the product error above:
{
productId: [
{
errorMessage: "invalid product id.",
attemptedValue: "1",
ruleName: "isNumber"
}
],
quantity: [
{
errorMessage: "Min qty is 1.",
attemptedValue: "1",
ruleName: "isNumber"
}
]
}This error object follows the shape of ErrorOf<Product>, meaning it mirrors the structure of the Product model.
Because of this, it's type-safe—if the Product model changes (e.g., a field is renamed or removed), TypeScript will catch the mismatch. No need to manually update your error structure. You get auto-synced validation typing for free. It handles the discipline for you, so you can just focus on writing the logic and the types, and the rest stays in sync without extra work.
🧬 Validation Context Awareness: Property, Root Object, and Arrays
Validant's validation rules are context-aware — giving you access to both the property being validated and the full object it's part of.
🔹Property-Level Awareness
You get full type info on the property:
interface Person {
name: string;
age: number;
}
const rule: ValidationRule<Person> = {
name: [required()],
age: [
function (age, person) {
if (age < 18) {
return {
ruleName: "Minimum age to drink beer.",
attemptedValue: age,
errorMessage: `We are sorry ${person.name}, You are not allowed to drink beer.`,
};
}
},
],
};Here, age is strongly typed as a number. IntelliSense works out of the box:
🔹Root object awareness
You also get access to the full object (person in this case), so you can create meaningful cross-field validations:
function (age, person) {
if (age < 18) {
return {
ruleName: "Minimum age to drink beer.",
attemptedValue: age,
errorMessage: `We are sorry ${person.name}, You are not allowed to drink beer.`,
};
}
}
person is correctly inferred as the root type Person:
🔹Array (Item) Awareness
Validant supports deep validation for arrays — including item-level rules with full context.
Example: Order Validation
interface Order {
id: string;
orderItems: OrderItem[];
}
interface OrderItem {
productId: number;
quantity: number;
discountPercentage: number;
}
const rule: ValidationRule<Order> = {
orderItems: {
arrayRules: [arrayMinLen(1)],
arrayElementRule: {
quantity: [minNumber(1, "Min qty is 1.")],
discountPercentage: [maxNumber(10)],
},
},
};discountPercentage: [maxNumber(10)]
The rule above limits discountPercentage to a static 10%. But what if the rules change?
Default max is 10%
If quantity >= 10, max discount increases to 30%
You can express that cleanly using a function for arrayElementRule:
const rule: ValidationRule<Order> = {
orderItems: {
arrayRules: [arrayMinLen(1)],
// here we assign the rule with function instead of object.
// The currentOrderItem is the current array item (context) that is being validated
arrayElementRule: function (currentOrderItem, order) {
return {
quantity: [minNumber(1, "Min qty is 1.")],
discountPercentage: [
currentOrderItem.quantity >= 10
? maxNumber(30)
: maxNumber(10),
],
};
},
},
};Here, item refers to the current array element, and order is the root object. And yes — it's fully type-safe.
Examples
Yeah, talk is cheap, here some examples:
Sync Example:
Order Validation
interface Customer {
fullName: string;
email: string;
}
interface OrderItem {
productId: number;
quantity: number;
}
interface OrderRequest {
orderNumber: string;
orderDate?: Date;
customer: Customer;
orderItems: OrderItem[];
}
const orderRule: ValidationRule<OrderRequest> = {
orderNumber: [required("Order number is required.")],
orderDate: [required("Please enter order date.")],
customer: {
fullName: [required()],
email: [required(), emailAddress()],
},
orderItems: {
arrayRules: [arrayMinLen(1, "Please add at least one product.")],
arrayElementRule: {
productId: [required("Please enter product.")],
quantity: [
minNumber(1, "Minimum quantity is 1."),
function (quantity, order) {
// Case:
// When customer first 3 letters contains : Jac ignore invariant
// Then Max Quantity = 100
// So Jack, Jacob, Jacky, Jacka will get this special max quantity
//
// Other than that
// Max quantity = 10
// Accessing other properties via order
const customerName = order.customer.fullName;
const isJac = order.customer.fullName
.toLowerCase()
.startsWith("jac");
const maxQuantityForJac = 100;
const maxQuantityForOthers = 10;
const isValidQuantityForJac = quantity <= maxQuantityForJac;
const isValidQuantityForOthers =
quantity <= maxQuantityForOthers;
if (isJac) {
if (!isValidQuantityForJac) {
return {
ruleName: "isJac",
attemptedValue: quantity,
errorMessage: `You are special ${customerName}, other's max quantity is limited to ${maxQuantityForOthers}. Yours is limited to, but ${maxQuantityForJac} pcs.`,
};
}
}
if (!isValidQuantityForOthers) {
return {
ruleName: "isJac",
attemptedValue: quantity,
errorMessage: `You only allowed to order ${maxQuantityForOthers} product at once.`,
};
}
},
],
},
},
};Nested Example
interface Continent {
name: string;
}
interface Country {
name: string;
continent: Continent;
}
interface City {
name: string;
country: Country;
}
interface Address {
street: string;
city: City;
}
interface Person {
name: string;
age: number;
child?: Person;
address?: Address;
}
const rule: ValidationRule<Person> = {
name: [required()],
age: [minNumber(20)],
address: {
street: [required()],
city: {
name: [required()],
country: {
name: [required()],
continent: {
name: [required()],
},
},
},
},
child: {
name: [required()],
},
};And the validation result :
{
message: "Validation failed. Please check and fix the errors to continue.",
isValid: false,
errors: {
name: [
{
errorMessage: "This field is required.",
attemptedValue: "",
ruleName: "required"
}
],
age: [
{
errorMessage: "The minimum value for this field is 20.",
attemptedValue: 0,
ruleName: "required"
}
],
address: {
street: [
{
errorMessage: "This field is required.",
attemptedValue: "",
ruleName: "required"
}
],
city: {
name: [
{
errorMessage: "This field is required.",
attemptedValue: "",
ruleName: "required"
}
],
country: {
name: [
{
errorMessage: "This field is required.",
attemptedValue: "",
ruleName: "required"
}
],
continent: {
name: [
{
errorMessage: "This field is required.",
attemptedValue: "",
ruleName: "required"
}
],
}
}
}
},
child: {
name: [
{
errorMessage: "This field is required.",
attemptedValue: "",
ruleName: "required"
}
],
}
},
child: {
name: [
{
errorMessage: "This field is required.",
attemptedValue: "",
ruleName: "required"
}
],
}
}
}Async Examples
Login that validates email
export type LoginRequest = {
email: string;
password: string;
};
function preventUnregisteredEmailRule(userRepository: UserRepository) {
return async function (email) {
if (!email) {
// lets skip this for now.
return;
}
const existingUser = await userRepository.getUserAsync(email);
if (!existingUser) {
return {
ruleName: preventUnregisteredEmailRule.name,
attemptedValue: email,
errorMessage: `${email} is not registered.`,
};
}
};
}
function buildLoginRule(userRepository: UserRepository) {
const registrationRule: AsyncValidationRule<LoginRequest> = {
email: [
required(),
emailAddress(),
preventRegisteredEmailRule(userRepository),
],
password: [required()],
};
return registrationRule;
}Product Validation
// Validates if price level is sequential or not
function sequentialPriceLevelRule(currentPriceItem: ProductPrice) {
return function (level: number, product: ProductRequest) {
if (!currentPriceItem)
throw new Error("Product price cannot be null or undefined.");
if (!product) throw new Error("Product cannot be null or undefined.");
if (!product.prices)
throw new Error("Product prices cannot be null or undefined.");
// Checks if price level is sequential
const currentPriceItemIndex = product.prices.indexOf(currentPriceItem);
const isFirstIndex = currentPriceItemIndex === 0;
// First index is ok: no comparer
if (isFirstIndex) {
return;
}
const prevPriceIndex = currentPriceItemIndex - 1;
const prevPrice = product.prices[prevPriceIndex];
if (!prevPrice)
throw new Error(
`Previous price item is expected defined. But got: ${prevPrice}`
);
const expectedNextPriceLevel = prevPrice.level + 1;
const isValid = level === expectedNextPriceLevel;
if (!isValid) {
return {
ruleName: sequentialPriceLevelRule.name,
attemptedValue: level,
errorMessage: `Price level should be sequential. And the current price level should be: ${expectedNextPriceLevel}, but got ${level}`,
};
}
};
}
function userCanCreateProductRule(userRepository: UserRepository) {
return async function (userEmail: string) {
const user = await userRepository.getUserAsync(userEmail);
if (!user) {
return {
ruleName: userCanCreateProductRule.name,
attemptedValue: userEmail,
errorMessage: `Invalid user email ${userEmail}.`,
};
}
if (user.userType !== "tenant") {
return {
ruleName: userCanCreateProductRule.name,
attemptedValue: userEmail,
errorMessage: `User is not allowed to create product.`,
};
}
};
}
function noDuplicatePriceLevelRule() {
return function (prices: ProductPrice[], product: ProductRequest) {
for (let index = 0; index < prices.length; index++) {
const productPrice = prices[index];
const isDuplicatePrice =
prices.filter(
(x) =>
x.level === productPrice.level &&
x.price === productPrice.price
).length > 1;
if (isDuplicatePrice) {
return {
ruleName: noDuplicatePriceLevelRule.name,
attemptedValue: prices,
errorMessage: `Duplicate price ${productPrice.price} and level ${productPrice.level}. At index ${index}.`,
};
}
}
};
}
function buildProductRule(userRepository: UserRepository) {
const productRequest: AsyncValidationRule<ProductRequest> = {
productName: [
required(),
stringMinLen(3, "Product name should be at least 3 chars"),
],
prices: {
arrayRules: [
required(),
arrayMinLen(1, "Product has to be at least having 1 price."),
arrayMaxLen(5, "Product prices maximum is 5 level."),
noDuplicatePriceLevelRule(),
],
arrayElementRule: function (
currentPriceItem: ProductPrice,
product: ProductRequest
) {
return {
level: [
required(),
minNumber(
1,
"Product level is a non 0 and positive number."
),
sequentialPriceLevelRule(currentPriceItem),
],
price: [
required(),
minNumber(1, "Minimum price is at least $1."),
],
};
},
},
userEmail: [userCanCreateProductRule(userRepository)],
};
return productRequest;
}
export interface ProductValidationService {
validateAsync(
request: ProductRequest
): Promise<ValidationResult<ProductRequest>>;
}
// Creates a validation service
export function createProductValidationService(
userRepository: UserRepository
): ProductValidationService {
async function validateAsync(request: ProductRequest) {
// pass the repository required by the validation rule builder for validation purpose
const registrationRule = buildProductRule(userRepository);
const validator = new AsyncValidator();
return validator.validateAsync(request, registrationRule);
}
return {
validateAsync,
};
}For more example please visit:
🧩 Validation Rule Composition
As your data grows in complexity, Validant makes it easy to split validation logic into smaller, reusable pieces.
You can compose validation Validation Rule by defining rules for nested objects or array items separately and plugging them into your main Validation Rule.
interface Address {
street: string