Fluent, chainable validation for JavaScript & TypeScript.
Zero dependencies. Under 3kb gzipped. Works everywhere.
npm install chain-validate
import { v } from 'chain-validate';
const schema = v.object({
name: v.string().required().trim().minLength(2),
email: v.string().required().trim().lowercase().email(),
age: v.number().optional().coerce().min(0),
});
const result = schema.validate({
name: ' KENIL ',
email: ' Kenil@Example.COM ',
age: '25'
});
// result.ok === true
// result.value = {
// name: "KENIL",
// email: "kenil@example.com",
// age: 25
// }
Chain validators and sanitizers in the order you want them to run.
result.value is already trimmed, lowercased, and coerced. No post-processing needed.
Check result.ok — TypeScript narrows the type automatically.
Everything you need. Nothing you don't.
Never short-circuits. Every rule runs and all failures are collected — perfect for forms showing multiple field errors simultaneously.
Returns {ok, value/errors} — a discriminated union. No try/catch. TypeScript narrows automatically on .ok.
.trim(), .lowercase(), .coerce() transform the value as it flows — result.value is already clean.
Zero dependencies. Tree-shakable. 5x smaller than Zod, 10x smaller than Joi. Your bundle will barely notice it.
31M ops/sec on simple validations. Zero allocations on valid input. Pre-compiled regex. No Proxy or getter traps.
Same import, same API. Works in Node.js for API validation and in the browser for form validation. Define once, use everywhere.
| Feature | chain-validate | Zod | Yup | Joi |
|---|---|---|---|---|
| Bundle size | ~2.7kb | ~14kb | ~12kb | ~30kb |
| Zero dependencies | Yes | Yes | Yes | No |
| Collects ALL errors | Yes | No | No | Config |
| Built-in sanitization | Yes | No | Partial | No |
| Never throws | Yes | No | No | No |
| TypeScript-first | Yes | Yes | Partial | No |
| Async validators | Yes | Yes | Yes | Yes |
| Conditional (.when) | Yes | No | Yes | Yes |
Pick a scenario, edit the input, see results live.
Every method, organized by chain type.
.required(msg?)Must not be empty/undefined/null.optional()Allows undefined/null.minLength(n, msg?)Minimum character count.maxLength(n, msg?)Maximum character count.length(n, msg?)Exact character count.email(msg?)RFC 5322 email format.url(msg?)Valid URL format.uuid(msg?)UUID v4 format.regex(pattern, msg?)Custom regex test.includes(str, msg?)Must contain substring.startsWith(str, msg?)Must start with prefix.endsWith(str, msg?)Must end with suffix.oneOf(values, msg?)Must be one of listed values.notEmpty(msg?)Not empty after trim.trim()Strip whitespace.lowercase()Convert to lowercase.uppercase()Convert to uppercase.replace(search, repl)String replacement.default(value)Fallback if nil.required(msg?)Must not be undefined/null/NaN.optional()Allows undefined/null.min(n, msg?)Minimum value (inclusive).max(n, msg?)Maximum value (inclusive).between(min, max, msg?)Value within range.integer(msg?)Must be whole number.positive(msg?)Must be > 0.negative(msg?)Must be < 0.oneOf(values, msg?)Must be one of listed.coerce()"42" → 42.round()Round to nearest int.floor()Round down.ceil()Round up.clamp(min, max)Clamp within range.default(value)Fallback if nil.required(msg?)Must not be nil.optional()Allows undefined/null.coerce()"true"/"yes"/1 → true.default(value)Fallback if nil.required(msg?)Must not be nil.optional()Allows undefined/null.of(chain)Validate each element.minLength(n, msg?)Min array length.maxLength(n, msg?)Max array length.unique(msg?)All elements unique.noEmpty(msg?)No empty elements.compact()Remove null/undefined.flat(depth?)Flatten nested arrays.default(value)Fallback if nil.required(msg?)Must not be nil.optional()Allows undefined/null.strict(msg?)Reject unknown keysnested pathsErrors include ["user","email"].when() fieldsConditional validation.or(otherChain)Union — passes if either passes.and(otherChain)Intersection — both must pass.when(field, opts)Conditional based on sibling field.test(name, msg, fn)Custom sync validator.testAsync(name, msg, fn)Custom async validatorv.custom(fn)Full control with ctx.addError()v.any()Accepts any typeCopy-paste ready code for common scenarios.
const createUserSchema = v.object({
name: v.string().required().trim().minLength(2),
email: v.string().required().trim().lowercase().email(),
password: v.string().required().minLength(8),
age: v.number().optional().coerce().integer().between(13, 150),
role: v.string().default('user').oneOf(['user', 'admin']),
});
app.post('/users', (req, res) => {
const result = createUserSchema.validate(req.body);
if (!result.ok) {
return res.status(400).json({
success: false,
errors: result.errors,
});
}
// result.value is typed, trimmed, lowered, coerced
const user = await createUser(result.value);
res.json({ success: true, data: user });
});
const loginSchema = v.object({
email: v.string().required('Email is required')
.email('Enter a valid email'),
password: v.string().required('Password is required')
.minLength(8, 'At least 8 characters'),
});
function LoginForm() {
const [errors, setErrors] = useState({});
const handleSubmit = (formData) => {
const result = loginSchema.validate(formData);
if (!result.ok) {
const fieldErrors = {};
result.errors.forEach((err) => {
fieldErrors[err.path[0]] = err.message;
});
setErrors(fieldErrors);
return;
}
setErrors({});
login(result.value);
};
}
const schema = v.object({
type: v.string().oneOf(['personal', 'business']),
company: v.string().when('type', {
is: 'business',
then: (chain) => chain.required('Company required'),
otherwise: (chain) => chain.optional(),
}),
taxId: v.string().when('type', {
is: 'business',
then: (chain) => chain.required().length(10),
otherwise: (chain) => chain.optional(),
}),
});
// Business: company + taxId required
// Personal: both optional
const password = v.custom((value, ctx) => {
if (typeof value !== 'string') {
ctx.addError('type', 'Must be a string');
return;
}
if (value.length < 8)
ctx.addError('minLength', 'At least 8 characters');
if (!/[A-Z]/.test(value))
ctx.addError('uppercase', 'Need one uppercase');
if (!/[0-9]/.test(value))
ctx.addError('digit', 'Need one digit');
if (!/[^a-zA-Z0-9]/.test(value))
ctx.addError('special', 'Need one special char');
return value;
});
// password.validate("abc")
// errors: minLength, uppercase, digit, special
// All 4 errors at once!
The discriminated union that powers everything.
{
ok: true,
value: "kenil@example.com"
}
Sanitized, typed, ready to use. TypeScript knows result.value exists.
{
ok: false,
errors: [
{
rule: "email",
message: "Invalid email",
path: ["user", "email"]
}
]
}
Every error collected. Path traces nested objects. Perfect for API responses or UI hints.
Where we've been. Where we're going.
String, Number, Boolean, Array, Object, Any, Custom chains with full validators and sanitizers.
Async validation with .testAsync() and conditional validation with .when().
Union types with .or() and intersections with .and().
Pluggable message resolver for multi-language support. Built-in en, community locale packs.
.toJSONSchema() on any chain for auto-generating OpenAPI/Swagger documentation.
DateChain with .past(), .future(). FileChain with .maxSize(), .mimeType() for multipart validation.