UNPKG

ow

Version:

Function argument validation for humans

261 lines (243 loc) 9.57 kB
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; } }