ow
Version:
Function argument validation for humans
292 lines (278 loc) • 10.7 kB
JavaScript
import is from '@sindresorhus/is';
import { Predicate } from './predicate.js';
export class StringPredicate extends Predicate {
/**
@hidden
*/
constructor(options, validators) {
super('string', options, validators);
}
/**
Test a string to have a specific length.
@param length - The length of the string.
*/
length(length) {
return this.addValidator({
message: (value, label) => `Expected ${label} to have length \`${length}\`, got \`${value}\``,
validator: value => value.length === length,
});
}
/**
Test a string to have a minimum length.
@param length - The minimum length of the string.
*/
minLength(length) {
return this.addValidator({
message: (value, label) => `Expected ${label} to have a minimum length of \`${length}\`, got \`${value}\``,
validator: value => value.length >= length,
negatedMessage: (value, label) => `Expected ${label} to have a maximum length of \`${length - 1}\`, got \`${value}\``,
});
}
/**
Test a string to have a maximum length.
@param length - The maximum length of the string.
*/
maxLength(length) {
return this.addValidator({
message: (value, label) => `Expected ${label} to have a maximum length of \`${length}\`, got \`${value}\``,
validator: value => value.length <= length,
negatedMessage: (value, label) => `Expected ${label} to have a minimum length of \`${length + 1}\`, got \`${value}\``,
});
}
/**
Test a string against a regular expression.
@param regex - The regular expression to match the value with.
*/
matches(regex) {
return this.addValidator({
message: (value, label) => `Expected ${label} to match \`${regex}\`, got \`${value}\``,
validator: value => regex.test(value),
});
}
/**
Test a string to start with a specific value.
@param searchString - The value that should be the start of the string.
*/
startsWith(searchString) {
return this.addValidator({
message: (value, label) => `Expected ${label} to start with \`${searchString}\`, got \`${value}\``,
validator: value => value.startsWith(searchString),
});
}
/**
Test a string to end with a specific value.
@param searchString - The value that should be the end of the string.
*/
endsWith(searchString) {
return this.addValidator({
message: (value, label) => `Expected ${label} to end with \`${searchString}\`, got \`${value}\``,
validator: value => value.endsWith(searchString),
});
}
/**
Test a string to include a specific value.
@param searchString - The value that should be included in the string.
*/
includes(searchString) {
return this.addValidator({
message: (value, label) => `Expected ${label} to include \`${searchString}\`, got \`${value}\``,
validator: value => value.includes(searchString),
});
}
/**
Test if the string is an element of the provided list.
@param list - List of possible values.
*/
oneOf(list) {
return this.addValidator({
message(value, label) {
let printedList = JSON.stringify(list);
if (list.length > 10) {
const overflow = list.length - 10;
printedList = JSON.stringify(list.slice(0, 10)).replace(/]$/, `,…+${overflow} more]`);
}
return `Expected ${label} to be one of \`${printedList}\`, got \`${value}\``;
},
validator: value => list.includes(value),
});
}
/**
Test a string to be empty.
*/
get empty() {
return this.addValidator({
message: (value, label) => `Expected ${label} to be empty, got \`${value}\``,
validator: value => value === '',
});
}
/**
Test a string to contain at least 1 non-whitespace character.
*/
get nonBlank() {
return this.addValidator({
message(value, label) {
// Unicode's formal substitute characters can be barely legible and may not be easily recognized.
// Hence this alternative substitution scheme.
const madeVisible = value
.replaceAll(' ', '·')
.replaceAll('\f', String.raw `\f`)
.replaceAll('\n', String.raw `\n`)
.replaceAll('\r', String.raw `\r`)
.replaceAll('\t', String.raw `\t`)
.replaceAll('\v', String.raw `\v`);
return `Expected ${label} to not be only whitespace, got \`${madeVisible}\``;
},
validator: value => value.trim() !== '',
});
}
/**
Test a string to be not empty.
*/
get nonEmpty() {
return this.addValidator({
message: (_, label) => `Expected ${label} to not be empty`,
validator: value => value !== '',
});
}
/**
Test a string to be equal to a specified string.
@param expected - Expected value to match.
*/
equals(expected) {
return this.addValidator({
message: (value, label) => `Expected ${label} to be equal to \`${expected}\`, got \`${value}\``,
validator: value => value === expected,
});
}
/**
Test a string to be alphanumeric.
*/
get alphanumeric() {
return this.addValidator({
message: (value, label) => `Expected ${label} to be alphanumeric, got \`${value}\``,
validator: value => /^[a-z\d]+$/i.test(value),
});
}
/**
Test a string to be alphabetical.
*/
get alphabetical() {
return this.addValidator({
message: (value, label) => `Expected ${label} to be alphabetical, got \`${value}\``,
validator: value => /^[a-z]+$/gi.test(value),
});
}
/**
Test a string to be numeric.
*/
get numeric() {
return this.addValidator({
message: (value, label) => `Expected ${label} to be numeric, got \`${value}\``,
validator: value => /^[+-]?\d+$/i.test(value),
});
}
/**
Test a string to be a valid date.
*/
get date() {
return this.addValidator({
message: (value, label) => `Expected ${label} to be a date, got \`${value}\``,
validator: value => is.validDate(new Date(value)),
});
}
/**
Test a non-empty string to be lowercase. Matching both alphabetical & numbers.
*/
get lowercase() {
return this.addValidator({
message: (value, label) => `Expected ${label} to be lowercase, got \`${value}\``,
validator: value => value.trim() !== '' && value === value.toLowerCase(),
});
}
/**
Test a non-empty string to be uppercase. Matching both alphabetical & numbers.
*/
get uppercase() {
return this.addValidator({
message: (value, label) => `Expected ${label} to be uppercase, got \`${value}\``,
validator: value => value.trim() !== '' && value === value.toUpperCase(),
});
}
/**
Test a string to be a valid URL.
*/
get url() {
return this.addValidator({
message: (value, label) => `Expected ${label} to be a URL, got \`${value}\``,
validator: is.urlString,
});
}
/**
Test a string to be a valid email address.
The validation is based on a practical subset of RFC 5322 that works for real-world email addresses.
This implementation balances strictness with usability and performance.
@example
```
import ow from 'ow';
ow('foo@example.com', ow.string.email);
//=> passes
ow('invalid.email', ow.string.email);
//=> ArgumentError: Expected string to be an email address, got `invalid.email`
```
*/
get email() {
return this.addValidator({
message: (value, label) => `Expected ${label} to be an email address, got \`${value}\``,
validator(value) {
// Maximum length check (RFC 5321)
if (value.length > 320) {
return false;
}
// Split into local and domain parts
const atIndex = value.lastIndexOf('@');
if (atIndex === -1) {
return false;
}
const localPart = value.slice(0, atIndex);
const domainPart = value.slice(atIndex + 1);
// Check local part length (max 64 characters per RFC)
if (localPart.length === 0 || localPart.length > 64) {
return false;
}
// Check domain part length (max 255 characters)
if (domainPart.length === 0 || domainPart.length > 255) {
return false;
}
// Validate local part
// This regex allows:
// - Alphanumeric characters and common symbols
// - Dots not at the beginning or end, and not consecutive
// - Quoted strings (basic support for printable ASCII)
const localPartRegex = /^(?:[\w!#$%&*+/=?^`{|}~-]+(?:\.[\w!#$%&*+/=?^`{|}~-]+)*|"[ !\u0023-\u005B\u005D-\u007E]+")$/;
if (!localPartRegex.test(localPart)) {
return false;
}
// Validate domain part
// Check for IPv4 literal
const ipv4Regex = /^\[(?:(?:25[0-5]|2[0-4]\d|[01]?\d{1,2})\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d{1,2})]$/;
if (ipv4Regex.test(domainPart)) {
return true;
}
// Check for IPv6 literal (simplified)
const ipv6Regex = /^\[ipv6:[\da-f:.]+]$/i;
if (ipv6Regex.test(domainPart)) {
// Basic IPv6 format check
return true;
}
// Validate domain name
// - Must contain at least one dot
// - Each label must be 1-63 characters
// - Labels can contain alphanumeric and hyphens
// - Labels cannot start or end with hyphens
// - TLD must be at least 2 characters
const domainRegex = /^(?:[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?\.)+[a-z]{2,}$/i;
return domainRegex.test(domainPart);
},
});
}
}