snap-validate
Version:
Zero-dependency, ~4KB validation library for common patterns. CJS + ESM, Standard Schema support, no TypeScript compiler required.
634 lines (551 loc) • 18.2 kB
JavaScript
/**
* Snap Validate - BaseValidator
* The chainable validator. Each method pushes a rule (sync) or async rule
* onto the instance and returns `this` for fluent chaining.
*/
const { ValidationResult } = require('./ValidationResult');
const {
isRegexSafe,
safeRegexTest,
safeRegexTestSync
} = require('../utils/safeRegex');
const { validate, validateAsync } = require('../schema/validate');
class BaseValidator {
constructor(value) {
this.value = value;
this.rules = [];
this.asyncRules = [];
this.isOptional = false;
// Deprecated no-op: a timer cannot interrupt a synchronous regex, so this
// value is no longer consulted. Retained only for backward compatibility.
this.regexTimeout = 1000;
this.fieldName = null; // Track field name for better error messages
}
// Set field name for contextual error messages
setFieldName(name) {
this.fieldName = name;
return this;
}
// Helper to format error messages with field context
_formatError(message) {
if (
this.fieldName &&
!message.toLowerCase().includes(this.fieldName.toLowerCase())
) {
return `${this.fieldName}: ${message}`;
}
return message;
}
// Returns true when the field is optional and effectively empty, so a rule
// can short-circuit as valid. Pass `false` to NOT treat '' as empty
// (array/object rules, where an empty string is a real, invalid value).
_shouldSkipOptional(emptyStringCounts = true) {
if (!this.isOptional) {
return false;
}
const v = this.value;
return v === null || v === undefined || (emptyStringCounts && v === '');
}
required(message = 'This field is required') {
this.rules.push(() => {
if (this._shouldSkipOptional()) {
return new ValidationResult(true);
}
if (
this.value === null ||
this.value === undefined ||
this.value === ''
) {
return new ValidationResult(false, [this._formatError(message)]);
}
return new ValidationResult(true);
});
return this;
}
optional() {
this.isOptional = true;
return this;
}
// Deprecated: retained for backward compatibility and chainability. Regex
// execution cannot be interrupted by a timeout on a single thread, so this
// value is no longer used. Safe to remove in a future major version.
setRegexTimeout(timeoutMs) {
this.regexTimeout = timeoutMs;
return this;
}
// Transform/sanitize data before validation
transform(fn, errorMessage = 'Transform function failed') {
this.rules.push(() => {
if (this.value != null && this.value !== '') {
try {
this.value = fn(this.value);
} catch (error) {
return new ValidationResult(false, [
this._formatError(`${errorMessage}: ${error.message}`)
]);
}
}
return new ValidationResult(true);
});
return this;
}
// Check if value equals another value
equals(compareValue, message) {
const defaultMessage = `Must equal ${compareValue}`;
this.rules.push(() => {
if (this._shouldSkipOptional()) {
return new ValidationResult(true);
}
if (this.value !== compareValue) {
return new ValidationResult(false, [
this._formatError(message || defaultMessage)
]);
}
return new ValidationResult(true);
});
return this;
}
// Check if value is one of allowed values
oneOf(allowedValues, message) {
const defaultMessage = `Must be one of: ${allowedValues.join(', ')}`;
this.rules.push(() => {
if (this._shouldSkipOptional()) {
return new ValidationResult(true);
}
if (!allowedValues.includes(this.value)) {
return new ValidationResult(false, [
this._formatError(message || defaultMessage)
]);
}
return new ValidationResult(true);
});
return this;
}
// Check if value is between min and max (for numbers)
between(min, max, message) {
const defaultMessage = `Must be between ${min} and ${max}`;
this.rules.push(() => {
if (this._shouldSkipOptional()) {
return new ValidationResult(true);
}
const numValue =
typeof this.value === 'number' ? this.value : parseFloat(this.value);
if (isNaN(numValue) || numValue < min || numValue > max) {
return new ValidationResult(false, [
this._formatError(message || defaultMessage)
]);
}
return new ValidationResult(true);
});
return this;
}
min(length, message = `Minimum length is ${length}`) {
this.rules.push(() => {
if (this._shouldSkipOptional()) {
return new ValidationResult(true);
}
if (this.value != null && this.value !== '') {
if (typeof this.value === 'string' || Array.isArray(this.value)) {
if (this.value.length < length) {
return new ValidationResult(false, [this._formatError(message)]);
}
} else if (typeof this.value === 'number') {
if (this.value < length) {
return new ValidationResult(false, [this._formatError(message)]);
}
} else {
return new ValidationResult(false, [
this._formatError('Value must be a string, array, or number')
]);
}
}
return new ValidationResult(true);
});
return this;
}
max(length, message = `Maximum length is ${length}`) {
this.rules.push(() => {
if (this._shouldSkipOptional()) {
return new ValidationResult(true);
}
if (this.value != null && this.value !== '') {
if (typeof this.value === 'string' || Array.isArray(this.value)) {
if (this.value.length > length) {
return new ValidationResult(false, [this._formatError(message)]);
}
} else if (typeof this.value === 'number') {
if (this.value > length) {
return new ValidationResult(false, [this._formatError(message)]);
}
} else {
return new ValidationResult(false, [
this._formatError('Value must be a string, array or number')
]);
}
}
return new ValidationResult(true);
});
return this;
}
// Validate that value is an array
array(message = 'Must be an array') {
this.rules.push(() => {
if (this._shouldSkipOptional(false)) {
return new ValidationResult(true);
}
if (!Array.isArray(this.value)) {
return new ValidationResult(false, [this._formatError(message)]);
}
return new ValidationResult(true);
});
return this;
}
// Validate each item in an array
arrayOf(validator, message = 'Invalid array items') {
this.rules.push(() => {
if (this._shouldSkipOptional(false)) {
return new ValidationResult(true);
}
if (!Array.isArray(this.value)) {
return new ValidationResult(false, [
this._formatError('Value must be an array')
]);
}
const errors = [];
this.value.forEach((item, index) => {
try {
const itemValidator =
typeof validator === 'function' ? validator(item) : validator;
const result = itemValidator.validate();
if (!result.isValid) {
errors.push(`[${index}]: ${result.errors.join(', ')}`);
}
} catch (error) {
errors.push(`[${index}]: Validation error - ${error.message}`);
}
});
if (errors.length > 0) {
return new ValidationResult(false, [
this._formatError(`${message}: ${errors.join('; ')}`)
]);
}
return new ValidationResult(true);
});
return this;
}
// Async array validation
arrayOfAsync(validator, message = 'Invalid array items') {
this.asyncRules.push(async () => {
if (this._shouldSkipOptional(false)) {
return new ValidationResult(true);
}
if (!Array.isArray(this.value)) {
return new ValidationResult(false, [
this._formatError('Value must be an array')
]);
}
const errors = [];
for (let index = 0; index < this.value.length; index++) {
try {
const item = this.value[index];
const itemValidator =
typeof validator === 'function' ? validator(item) : validator;
const result =
itemValidator.asyncRules && itemValidator.asyncRules.length > 0
? await itemValidator.validateAsync()
: itemValidator.validate();
if (!result.isValid) {
errors.push(`[${index}]: ${result.errors.join(', ')}`);
}
} catch (error) {
errors.push(`[${index}]: Validation error - ${error.message}`);
}
}
if (errors.length > 0) {
return new ValidationResult(false, [
this._formatError(`${message}: ${errors.join('; ')}`)
]);
}
return new ValidationResult(true);
});
return this;
}
// Validate nested objects
object(schema, message = 'Invalid object structure') {
this.rules.push(() => {
if (this._shouldSkipOptional(false)) {
return new ValidationResult(true);
}
if (
typeof this.value !== 'object' ||
this.value === null ||
Array.isArray(this.value)
) {
return new ValidationResult(false, [this._formatError(message)]);
}
try {
const result = validate(schema, this.value);
if (!result.isValid) {
const fieldErrors = result.getErrors();
const errorMessages = Object.entries(fieldErrors)
.map(([field, errs]) => `${field}: ${errs.join(', ')}`)
.join('; ');
return new ValidationResult(false, [
this._formatError(`Object validation failed - ${errorMessages}`)
]);
}
return new ValidationResult(true);
} catch (error) {
return new ValidationResult(false, [
this._formatError(`Object validation error: ${error.message}`)
]);
}
});
return this;
}
// Async nested object validation
objectAsync(schema, message = 'Invalid object structure') {
this.asyncRules.push(async () => {
if (this._shouldSkipOptional(false)) {
return new ValidationResult(true);
}
if (
typeof this.value !== 'object' ||
this.value === null ||
Array.isArray(this.value)
) {
return new ValidationResult(false, [this._formatError(message)]);
}
try {
const result = await validateAsync(schema, this.value);
if (!result.isValid) {
const fieldErrors = result.getErrors();
const errorMessages = Object.entries(fieldErrors)
.map(([field, errs]) => `${field}: ${errs.join(', ')}`)
.join('; ');
return new ValidationResult(false, [
this._formatError(`Object validation failed - ${errorMessages}`)
]);
}
return new ValidationResult(true);
} catch (error) {
return new ValidationResult(false, [
this._formatError(`Object validation error: ${error.message}`)
]);
}
});
return this;
}
pattern(regex, message = 'Invalid format') {
if (!isRegexSafe(regex)) {
throw new Error(
'Potentially unsafe regex pattern detected. Please use a simple pattern.'
);
}
this.rules.push(() => {
if (this._shouldSkipOptional()) {
return new ValidationResult(true);
}
if (this.value != null && this.value !== '') {
const stringValue = String(this.value);
try {
if (!safeRegexTestSync(regex, stringValue)) {
return new ValidationResult(false, [this._formatError(message)]);
}
} catch (error) {
if (error.message.includes('Input too long')) {
return new ValidationResult(false, [
this._formatError('Input too long for pattern validation')
]);
}
return new ValidationResult(false, [
this._formatError('Pattern validation failed')
]);
}
}
return new ValidationResult(true);
});
return this;
}
patternAsync(regex, message = 'Invalid format') {
if (!isRegexSafe(regex)) {
throw new Error(
'Potentially unsafe regex pattern detected. Please use a simple pattern.'
);
}
this.asyncRules.push(async () => {
if (this._shouldSkipOptional()) {
return new ValidationResult(true);
}
if (this.value != null && this.value !== '') {
const stringValue = String(this.value);
if (stringValue.length > 10000) {
return new ValidationResult(false, [
this._formatError('Input too long for pattern validation')
]);
}
try {
const result = await safeRegexTest(regex, stringValue);
if (!result) {
return new ValidationResult(false, [this._formatError(message)]);
}
} catch {
return new ValidationResult(false, [
this._formatError('Pattern validation failed')
]);
}
}
return new ValidationResult(true);
});
return this;
}
when(condition, validator) {
this.rules.push(() => {
const shouldValidate =
typeof condition === 'function' ? condition(this.value) : condition;
if (shouldValidate) {
if (typeof validator === 'function') {
const conditionalValidator = validator(this.value);
return conditionalValidator.validate();
} else {
return validator.validate();
}
}
return new ValidationResult(true);
});
return this;
}
custom(validatorFn, message = 'Custom validation failed') {
this.rules.push(() => {
if (this._shouldSkipOptional()) {
return new ValidationResult(true);
}
try {
const result = validatorFn(this.value);
if (typeof result === 'boolean') {
return result
? new ValidationResult(true)
: new ValidationResult(false, [this._formatError(message)]);
}
if (result && typeof result === 'object' && 'isValid' in result) {
return result;
}
if (typeof result === 'string') {
return new ValidationResult(false, [this._formatError(result)]);
}
return new ValidationResult(true);
} catch (error) {
return new ValidationResult(false, [
this._formatError(`Custom validation error: ${error.message}`)
]);
}
});
return this;
}
customAsync(validatorFn, message = 'Async validation failed') {
this.asyncRules.push(async () => {
if (this._shouldSkipOptional()) {
return new ValidationResult(true);
}
try {
const result = await validatorFn(this.value);
if (typeof result === 'boolean') {
return result
? new ValidationResult(true)
: new ValidationResult(false, [this._formatError(message)]);
}
if (result && typeof result === 'object' && 'isValid' in result) {
return result;
}
if (typeof result === 'string') {
return new ValidationResult(false, [this._formatError(result)]);
}
return new ValidationResult(true);
} catch (error) {
return new ValidationResult(false, [
this._formatError(`Async validation error: ${error.message}`)
]);
}
});
return this;
}
validate() {
const result = new ValidationResult(true);
for (const rule of this.rules) {
try {
const ruleResult = rule();
if (!ruleResult.isValid) {
result.isValid = false;
result.errors.push(...ruleResult.errors);
}
} catch (error) {
result.isValid = false;
result.errors.push(
this._formatError(`Validation error: ${error.message}`)
);
}
}
return result;
}
async validateAsync() {
const syncResult = this.validate();
if (!syncResult.isValid) {
return syncResult;
}
const result = new ValidationResult(true, [...syncResult.errors]);
for (const asyncRule of this.asyncRules) {
try {
const ruleResult = await asyncRule();
if (!ruleResult.isValid) {
result.isValid = false;
result.errors.push(...ruleResult.errors);
}
} catch (error) {
result.isValid = false;
result.errors.push(
this._formatError(`Async validation error: ${error.message}`)
);
}
}
return result;
}
}
/**
* Standard Schema v1 (https://standardschema.dev) support on instances.
*
* Defined as a prototype getter so every BaseValidator carries `~standard`
* with zero per-instance cost. Calling `schema['~standard'].validate(value)`
* rebinds the instance's value and runs its accumulated rules.
*
* Note: a BaseValidator instance holds ONE value at a time, so an instance
* used this way is safe for sequential reuse but not for CONCURRENT async
* validation of different values. For a fully reusable / concurrency-safe
* schema, wrap a factory with toStandardSchema() from src/standard.js.
*/
Object.defineProperty(BaseValidator.prototype, '~standard', {
configurable: true,
get() {
const self = this;
return {
version: 1,
vendor: 'snap-validate',
validate(value) {
self.value = value;
if (self.asyncRules.length > 0) {
return self
.validateAsync()
.then((result) =>
result.isValid
? { value: self.value }
: { issues: result.errors.map((message) => ({ message })) }
);
}
const result = self.validate();
return result.isValid
? { value: self.value }
: { issues: result.errors.map((message) => ({ message })) };
}
};
}
});
module.exports = { BaseValidator };