ow
Version:
Function argument validation for humans
261 lines (243 loc) • 9.57 kB
JavaScript
import is from '@sindresorhus/is';
import { ArgumentError } from '../argument-error.js';
import { not } from '../operators/not.js';
import { generateArgumentErrorMessage } from '../utils/generate-argument-error-message.js';
import { testSymbol, optionalSymbol, nullableSymbol, absentSymbol, } from './base-predicate.js';
/**
@hidden
*/
export const validatorSymbol = Symbol('validators');
/**
@hidden
*/
export class Predicate {
[optionalSymbol];
[nullableSymbol];
[absentSymbol];
type;
context;
options;
constructor(type, options = {}, existingValidators) {
this.type = type;
this.options = options;
this.context = {
validators: existingValidators ? [...existingValidators] : [],
...this.options,
};
// Expose optional status via symbol
if (this.options.optional === true) {
this[optionalSymbol] = true;
}
// Expose nullable status via symbol
if (this.options.nullable === true) {
this[nullableSymbol] = true;
}
// Expose absent status via symbol
if (this.options.absent === true) {
this[absentSymbol] = true;
}
// Only add the type validator if we don't have existing validators
// (i.e., this is a fresh predicate, not a clone)
if (!existingValidators) {
const typeString = this.type.charAt(0).toLowerCase() + this.type.slice(1);
this.context.validators.push({
message: (value, label) => {
// We do not include type in this label as we do for other messages, because it would be redundant.
const label_ = label?.slice(this.type.length + 1);
// TODO: The NaN check can be removed when `@sindresorhus/is` is fixed: https://github.com/sindresorhus/ow/issues/231#issuecomment-1047100612
return `Expected ${label_ || 'argument'} to be of type \`${this.type}\` but received type \`${Number.isNaN(value) ? 'NaN' : is(value)}\``;
},
validator: value => is[typeString](value),
});
}
}
/**
@hidden
*/
[testSymbol](value, main, label, idLabel) {
// Create a map of labels -> received errors.
const errors = new Map();
for (const { validator, message } of this.context.validators) {
// Skip validation if value matches an allowed modifier
if ((this.options.optional === true && value === undefined)
|| (this.options.nullable === true && value === null)) {
continue;
}
let result;
try {
result = validator(value);
}
catch (error) {
// Any errors caught means validators couldn't process the input.
result = error;
}
if (result === true) {
continue;
}
const label2 = is.function(label) ? label() : label;
const labelWithTick = (label2 && idLabel) ? `\`${label2}\`` : label2;
const label_ = labelWithTick
? `${this.type} ${labelWithTick}`
: this.type;
const mapKey = label2 || this.type;
// Get the current errors encountered for this label.
const currentErrors = errors.get(mapKey);
// Pre-generate the error message that will be reported to the user.
const errorMessage = message(value, label_, result);
// If we already have any errors for this label.
if (currentErrors) {
// If we don't already have this error logged, add it.
currentErrors.add(errorMessage);
}
else {
// Set this label and error in the full map.
errors.set(mapKey, new Set([errorMessage]));
}
}
// If we have any errors to report, throw.
if (errors.size > 0) {
// Generate the `error.message` property.
const message = generateArgumentErrorMessage(errors);
throw new ArgumentError(message, main, errors);
}
}
/**
@hidden
*/
get [validatorSymbol]() {
return this.context.validators;
}
/**
Invert the following validators.
*/
get not() {
return not(this);
}
/**
Test if the value matches a custom validation function. The validation function should return an object containing a `validator` and `message`. If the `validator` is `false`, the validation fails and the `message` will be used as error message. If the `message` is a function, the function is invoked with the `label` as argument to let you further customize the error message.
@param customValidator - Custom validation function.
*/
validate(customValidator) {
return this.addValidator({
message: (_, label, error) => typeof error === 'string'
? `(${label}) ${error}`
: error(label),
validator(value) {
const { message, validator } = customValidator(value);
if (validator) {
return true;
}
return message;
},
});
}
/**
Test if the value matches a custom validation function. The validation function should return `true` if the value passes the function. If the function either returns `false` or a string, the function fails and the string will be used as error message.
@param validator - Validation function.
*/
is(validator) {
return this.addValidator({
message: (value, label, error) => (error
? `(${label}) ${String(error)}`
: `Expected ${label} \`${String(value)}\` to pass custom validation function`),
validator,
});
}
/**
Use a custom validation function that throws an error when the validation fails. This is useful for reusing existing validators or composing complex validations.
@param customValidator - Custom validation function that throws an error if the value is invalid.
@example
```
import ow from 'ow';
interface User {
name: string;
age: number;
}
const validateUser = (user: User) => {
ow(user.name, 'User.name', ow.string.nonEmpty);
ow(user.age, 'User.age', ow.number.integer.positive);
};
ow([{name: 'Alice', age: 30}], ow.array.ofType(ow.object.custom(validateUser)));
```
@example
This is particularly useful when you have existing validation functions and want to compose them:
```
import ow from 'ow';
interface Animal {
type: string;
weight: number;
}
const validateAnimal = (animal: Animal) => {
ow(animal.type, 'Animal.type', ow.string.oneOf(['dog', 'cat', 'elephant']));
ow(animal.weight, 'Animal.weight', ow.number.finite.positive);
};
const animals: Animal[] = [
{type: 'dog', weight: 5},
{type: 'cat', weight: Number.POSITIVE_INFINITY}
];
ow(animals, ow.array.ofType(ow.object.custom(validateAnimal)));
//=> ArgumentError: (array) (object) Expected number `Animal.weight` to be finite, got Infinity
```
*/
custom(customValidator) {
return this.is((value) => {
try {
customValidator(value);
return true;
}
catch (error) {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
});
}
/**
Provide a new error message to be thrown when the validation fails.
@param newMessage - Either a string containing the new message or a function returning the new message.
@example
```
ow('🌈', 'unicorn', ow.string.equals('🦄').message('Expected unicorn, got rainbow'));
//=> ArgumentError: Expected unicorn, got rainbow
```
@example
```
ow('🌈', ow.string.minLength(5).message((value, label) => `Expected ${label}, to have a minimum length of 5, got \`${value}\``));
//=> ArgumentError: Expected string, to be have a minimum length of 5, got `🌈`
```
*/
message(newMessage) {
const validators = [...this.context.validators];
const lastValidator = validators.at(-1);
if (lastValidator) {
validators[validators.length - 1] = {
...lastValidator,
message: (value, label) => typeof newMessage === 'function'
? newMessage(value, label)
: newMessage,
};
}
return this.withValidators(validators);
}
/**
Register a new validator.
@param validator - Validator to register.
*/
addValidator(validator) {
// Create a new array with the existing validators plus the new one
const validators = [...this.context.validators, validator];
// Return a new instance with the updated validators
return this.withValidators(validators);
}
/**
@hidden
Create a new instance with the given validators
*/
withValidators(validators) {
// eslint-disable-next-line @typescript-eslint/naming-convention
const Constructor = this.constructor;
const instance = new Constructor(this.options, validators);
return instance;
}
}