Available Rules
@holo-js/validation has seven field types. You chain rules on a field in any order; transform() and custom rules run in the order you write them. Every rule that checks a value takes an optional custom message as its last argument. See Error Messages.
Available rules
Rules are grouped by the field types they apply to. Rules marked "all fields" work on every type.
Field types
field.string(),field.password(),field.number(),field.boolean(),field.date(),field.file(),field.array()
All fields
String fields
Number fields
min(),max(), andsize(), comparing the number itselfdigits(),minDigits(), andmaxDigits(), counting decimal digitsinteger()in()
Array fields
Date fields
before()andafter()beforeOrEqual()andafterOrEqual()today()beforeToday()andafterToday()todayOrBefore()andbeforeOrToday()todayOrAfter()andafterOrToday()
File fields
max()andmaxSize(), limiting bytessize(), exact byte countimage()imageAspectRatio()
Field types
Each builder creates a field with a fixed output type. Chain rules after it.
| Builder | Output type | Accepts |
|---|---|---|
field.string() | string | Strings |
field.password() | string | Strings. Same as string() but marked sensitive, and min() counts raw characters without trimming. |
field.number() | number | Numbers, or numeric strings such as '42' |
field.boolean() | boolean | Booleans, or '1'/'true'/'on'/'yes' and '0'/'false'/'off'/'no' |
field.date() | Date | Date objects, or strings new Date() can parse |
field.file() | WebFileLike (a Blob with an optional name) | Uploaded files from FormData |
field.array(item) | item[] | Arrays. item is another field or an object shape. |
import { field, schema } from '@holo-js/validation'
export const updateProfile = schema({
displayName: field.string().required().min(3).max(80),
website: field.string().optional().url(),
birthday: field.date().nullable().beforeToday(),
avatar: field.file().optional().image().maxSize('2mb'),
})Arrays
Pass a field for a list of values, or an object for a list of records. Array rules such as min(), max(), and size() count items.
import { field, schema } from '@holo-js/validation'
export const gallery = schema({
tags: field.array(field.string().required()).min(1).max(10),
images: field.array({
title: field.string().required(),
caption: field.string().optional(),
}),
})Errors for items use their index, such as tags.0 or images.2.title.
Holo checks max() and size() before it validates any item. A list with more items than the limit fails with the size error, and its items aren't checked.
Presence rules
required(message?)
The value must be present. undefined, null, whitespace-only strings, and empty arrays fail with "This field is required."
field.string().required()optional()
Allows the value to be missing (undefined). The output type becomes T | undefined.
field.string().optional()nullable()
Allows null. The output type becomes T | null.
field.date().nullable()A nullable array accepts null without checking its items. Rules on the item still apply to each item you send:
field.array(field.string().required().min(2)).optional().nullable()This accepts null, a missing value, [], or ['Valid'], and rejects [null], [''], and ['x'].
default(value)
Uses value when the input is missing. The field becomes optional, and the output type no longer includes undefined.
field.boolean().default(false)
field.array(field.number()).default([])Size rules
min, max, and size mean different things depending on the field type:
| Rule | string | number | array | file |
|---|---|---|---|---|
min(n) | At least n characters | At least n | At least n items | Not supported |
max(n) | At most n characters | At most n | At most n items | At most n bytes |
size(n) | Exactly n characters | Exactly n | Exactly n items | Exactly n bytes |
min(value, message?)
The minimum length for strings and arrays, or the minimum value for numbers. For string(), surrounding whitespace is ignored, so ' a ' has length 1. For password() and fields marked sensitive(), every character counts.
field.string().min(3)
field.number().min(1)
field.array(field.string()).min(1)max(value, message?)
The maximum length, value, or item count. On file fields, the value is bytes or a size string such as '2mb'. Size strings are only allowed on file fields.
field.string().max(255)
field.number().max(100)
field.file().max('2mb')size(value, message?)
The exact length, value, item count, or byte count. Use it for fixed-length codes.
field.string().size(6)
field.array(field.string()).size(3)maxSize(value, message?)
The maximum file size. Pass bytes as a number, or a string with kb, mb, or gb (1 kb = 1024 bytes). Decimal amounts such as '1.5mb' work. Any other format throws a ValidationContractError when you define the schema.
field.file().maxSize('2mb')
field.file().maxSize(500_000)max('2mb') does the same thing on file fields.
String rules
These apply to string fields.
email(message?)
Must be a valid email address.
field.string().email()url(message?)
Must be a valid URL.
field.string().url()uuid(message?)
Must be a valid UUID.
field.string().uuid()regex(pattern, message?)
Must match a regular expression. Pass a RegExp, not a string. Passing anything else throws a ValidationContractError.
field.string().regex(/^[a-z0-9-]+$/, 'Use lowercase letters, numbers, and dashes.')Number rules
digits(count, message?)
Requires exactly count decimal digits on a number field. minDigits() and maxDigits() set inclusive lower and upper digit counts. Each rule preserves the number output type and checks independently of min(), max(), and size(), which compare numeric values.
field.number().digits(3)
field.number().minDigits(2).maxDigits(4).max(2000)digits(3) accepts 123 and rejects 12 and 1234. Its default error is "This field must have exactly 3 digits."
Digit rules accept nonnegative finite integers. Zero (including JavaScript's -0) has one digit; negative and fractional numbers, NaN, and infinities fail. Holo parses numeric request strings using the existing number-field rules before counting, so leading zeros and exponent spelling do not affect the result. For example, '00123' and '1.23e2' both become 123, and 1e21 has 22 digits. Counts use the parsed JavaScript number's canonical decimal representation, with scientific notation expanded: 1e23 has 24 digits. Precision already lost in a JavaScript number cannot be recovered.
count must be a nonnegative safe integer; invalid counts and use on non-number fields throw ValidationContractError when declaring the rule. digits(0) and maxDigits(0) reject every present number; minDigits(0) accepts every nonnegative finite integer. Missing and null values follow the usual optional(), nullable(), and default() rules.
Serialized FieldDefinition.rules use the camelCase names digits, minDigits, and maxDigits, with args: [count] and an optional message. All three appear in SUPPORTED_RULE_FAMILIES. Pass a custom message to provide localized text.
minDigits(count, message?)
Requires at least count digits, using the same numeric and argument rules as digits().
field.number().minDigits(3)Accepts 123 and 1234; rejects 12. The default error is "This field must have at least 3 digits."
maxDigits(count, message?)
Requires at most count digits, using the same numeric and argument rules as digits().
field.number().maxDigits(3)Accepts 12 and 123; rejects 1234. The default error is "This field must have at most 3 digits."
integer(message?)
Must be a whole number.
field.number().integer().min(1)Choice rules
in(values, message?)
Must be one of the listed values. With as const, the output type narrows to those values. The list must not be empty.
field.string().in(['draft', 'published', 'archived'] as const)
field.number().in([10, 25, 50] as const)confirmed(message?)
Must equal a sibling field with the same name plus Confirmation. For example, password is compared with passwordConfirmation.
import { field, schema } from '@holo-js/validation'
export const changePassword = schema({
password: field.password().required().min(8).confirmed(),
passwordConfirmation: field.password().required(),
})Inside an array of objects, each item compares with its own confirmation field.
If confirmed() comes after a transform(), it compares the transformed value with the confirmation field's parsed output. Apply the same transform to both fields:
const passwords = schema({
password: field.string().transform(value => value.trim()).confirmed(),
passwordConfirmation: field.string().transform(value => value.trim()),
})Date rules
These apply to date fields. The comparison rules take another date as a Date or a date string. An invalid Date throws a ValidationContractError.
before(date, message?)
The value must be earlier than date.
field.date().before('2030-01-01')beforeOrEqual(date, message?)
The value must be earlier than or equal to date.
field.date().beforeOrEqual(new Date('2026-12-31'))after(date, message?)
The value must be later than date.
field.date().after('2026-01-01')afterOrEqual(date, message?)
The value must be later than or equal to date.
field.date().afterOrEqual('2026-01-01')today(message?)
The value must be today (local time).
field.date().today()beforeToday(message?)
The value must be before the start of today.
field.date().beforeToday('Your birthday must be in the past.')afterToday(message?)
The value must be after the end of today.
field.date().afterToday()todayOrBefore(message?)
The value must be today or earlier. beforeOrToday() is the same rule with a different name.
field.date().todayOrBefore()beforeOrToday(message?)
Same as todayOrBefore(). Use whichever name reads better.
field.date().beforeOrToday()todayOrAfter(message?)
The value must be today or later. afterOrToday() is the same rule with a different name.
field.date().todayOrAfter()afterOrToday(message?)
Same as todayOrAfter(). Use whichever name reads better.
field.date().afterOrToday()File rules
image(message?)
The uploaded file must be a complete, readable JPEG, PNG, GIF, WebP, AVIF, or TIFF image.
field.file().required().image().maxSize('5mb')Holo checks the file's contents, not its name or the type the browser reports. A script renamed to avatar.png fails. SVG files fail too, because they can contain scripts. If you need SVG uploads, accept them in a media collection with acceptsMimeTypes(['image/svg+xml']) and never show them inline.
On the server, Holo also decodes the whole image, including every frame of an animated GIF or WebP. A damaged or cut-off file fails, and so does an image larger than 40 million pixels in total or with more than 1,000 frames. This needs the sharp package in your app:
npm install sharpWithout it, server-side validation fails with an error asking you to install it. In the browser, image() checks the file's contents quickly and leaves the full decode to the server.
Add maxSize() to image fields. Holo checks the size first: an uploaded file larger than its max(), maxSize(), or size() limit is never decoded and fails with the size error. Size rules check the value at their own place in the chain. If a transform() before them returns a larger file, that file is checked against the size rule, not decoded, and fails with the size message you configured.
imageAspectRatio(ratios, message?)
Require an image with a particular width-to-height ratio. Pass one 'width:height' string or a nonempty array of allowed ratios:
import { field, schema } from '@holo-js/validation'
export const profileImages = schema({
avatar: field.file().required().imageAspectRatio('1:1', 'Upload a square avatar.'),
cover: field.file().optional().image().imageAspectRatio(['1:1', '16:9']),
})The file must match at least one ratio in the list. Width and height must be positive, finite numbers; invalid ratios or an empty list throw a ValidationContractError when you define the schema.
This rule performs the same image safety checks as image(), even when you omit .image(). Combining both rules reuses the image decode. It validates the original file without resizing or cropping it.
Ratios use encoded dimensions before EXIF rotation. Animated images use the height of one frame. Small differences from pixel rounding are allowed: the tolerance for Math.abs(requestedRatio - width / height) is 1 / (Math.max((width + height) / 2, height) + 1).
Ratio enforcement runs on the Node.js server and requires sharp, as described above. Browser validation defers the ratio check to the server, so always validate uploads there too.
Sensitive data
sensitive()
Marks a field as sensitive. Holo leaves sensitive values out of every validation failure: the 422 response, the values and toJSON() of a ValidationException, submission.fail(), submission.serialize(), and flashed form errors. useForm() clears them after a failed submission.
Password fields (field.password()) and fields named password, passwordConfirmation, password_confirmation, currentPassword, current_password, newPassword, new_password, confirmPassword, or confirm_password are always sensitive. Failure payloads leave them out even inside nested objects and arrays, and leave out uploaded files too. useForm() clears the same fields, including every item of a sensitive array.
field.string().sensitive().optional()Custom logic
transform(fn)
Changes the value. The return value is passed to the next rules and becomes the output type.
field.string()
.transform(value => value.trim())
.custom(value => value.startsWith('holo_') || 'Must start with holo_.')Rules only see the value at their position in the chain. Here custom() receives the trimmed string; put it before transform() to check the raw string.
If a custom rule before a transform fails, that transform and every rule after it are skipped. Rules before it can still add errors.
Array items are validated before a transform on the array itself. If an item fails, the array transform doesn't run, so you can't filter out bad items with it:
const post = schema({
tags: field.array(
field.string().custom(value => value !== 'blocked' || 'This tag is blocked.'),
).transform(values => values.filter(value => value !== 'blocked')),
})Submitting ['blocked', 'allowed'] fails with an error on tags.0. To skip items entirely, filter the input before you validate it.
custom(fn, message?)
Adds your own synchronous check. Return true to pass, false to fail with the default or supplied message, or a string to fail with that string.
field.string().custom(value => !value.includes(' ') || 'Spaces are not allowed.')customAsync(fn, message?)
Same as custom(), but the function can be async. Holo runs async checks one at a time, in field order.
import { field } from '@holo-js/validation'
import User from '@/server/models/User'
field.string().required().email().customAsync(async (email) => {
const existing = await User.where('email', email).first()
return !existing || 'This email is already registered.'
})Custom rules only run when the value is present and has the right type.
If your function throws, Holo doesn't turn the error into a validation message. The error reaches your framework's error handling like any other server error, so a failed database query responds with a 500 instead of a 422.