Validation
@holo-js/validation checks incoming data against a schema and gives you back typed, cleaned-up values. Use it for JSON APIs, query strings, uploads, CLI input, or anywhere else you need to trust data before using it.
If you are handling an HTML form, also read Forms. Forms are built on top of validation.
Quick example
import { field, schema } from '@holo-js/validation'
export const createPost = schema({
title: field.string().required().min(3).max(255),
body: field.string().required(),
status: field.string().required().in(['draft', 'published'] as const),
publishAt: field.date().optional().afterOrToday(),
})import { validate } from '@holo-js/validation'
import { createPost } from './schemas/post'
const data = await validate({
title: 'Hello Holo',
body: 'First post.',
status: 'draft',
}, createPost)
data.title
data.statusdata is fully typed: data.status is 'draft' | 'published', and data.publishAt is Date | undefined. If the input is invalid, validate() throws a ValidationException and Holo turns it into a 422 response.
Install
Pick validation (or forms, which includes it) when you create the project. To add it later, install @holo-js/validation with your package manager.
Define a schema
A schema is an object of fields. Each field starts with a type and chains rules:
import { field, schema } from '@holo-js/validation'
export const registerUser = schema({
name: field.string().required().max(80),
email: field.string().required().email(),
password: field.password().required().min(8).confirmed(),
passwordConfirmation: field.password().required(),
age: field.number().integer().min(13).optional(),
newsletter: field.boolean().default(false),
avatar: field.file().optional().image().maxSize('2mb'),
tags: field.array(field.string()).max(5).default([]),
})The available types are string, password, number, boolean, date, file, and array. See Available Rules for every rule.
schema() and defineSchema() are the same function. Use either name.
Nested objects
Nest plain objects to validate nested data. Error paths use dots, such as address.city.
const checkout = schema({
email: field.string().required().email(),
address: {
street: field.string().required(),
city: field.string().required(),
postcode: field.string().required().max(10),
},
})Required, optional, and nullable
Every field must be present and of the right type unless you say otherwise:
| You write | Accepts undefined | Accepts null | Rejects empty strings and [] |
|---|---|---|---|
field.string() | No | No | No |
field.string().required() | No | No | Yes |
field.string().optional() | Yes | No | No |
field.string().nullable() | No | Yes | No |
field.string().optional().nullable() | Yes | Yes | No |
field.boolean().default(false) | Yes, becomes false | No | No |
Use required() on strings when a whitespace-only value should fail with "This field is required."
Validate different inputs
validate(), safeParse(), and parse() accept a web Request, FormData, URLSearchParams, or a plain object.
import { validate } from '@holo-js/validation'
import { createPost } from '@/server/schemas/post'
import Post from '@/server/models/Post'
export async function POST(request: Request) {
const data = await validate(request, createPost)
const post = await Post.create(data)
return Response.json(post, { status: 201 })
}When you pass a Request, Holo reads it based on the method and content type:
| Request | Data read from |
|---|---|
GET or HEAD | Query string |
application/json | JSON body |
multipart/form-data or application/x-www-form-urlencoded | Form body |
| Empty body | Treated as {} |
A JSON or form body that can't be parsed is treated as {}, so required fields fail with their usual messages. Any other content type throws an error.
Form fields can use dot or bracket names for nested data: address.city, address[city], tags[], and tags[0] all work. A repeated name such as tags=a&tags=b becomes an array.
Input limits
Holo limits form bodies and query strings, so a small request can't make validation do a lot of work:
- A request can send at most 1,000 fields, the same default as PHP's
max_input_vars. Uploaded files don't count toward this limit; a form can separately send at most 1,000 uploaded-file entries. Repeated files count as separate entries. - A name can have at most 64 nesting levels after its root, matching PHP's default
max_input_nesting_level. Both dots and brackets add levels:address.cityandaddress[city]each have one. Holo rejects deeper names instead of silently dropping them. - A list index must be lower than the number of different indexes sent for that list.
tags[1]withtags[0]works, buttags[5]on its own fails. - Names that use
__proto__,constructor, orprototypefail. So do brackets that aren't closed or are closed without an opening bracket, such astags[0ortags]. - A name must keep one shape.
tags[name]can't be combined withtags[],tags[0], ortags=x, and numbered indexes (tags[0]) can't be combined withtags[]or repeated names, because the order of the fields would change the result. Repeated names andtags[]can be mixed, such astags=a&tags[]=b, which gives['a', 'b'].
These requests don't throw. They fail validation with one of these errors on _root, so validate() responds with a 422:
| Problem | Message |
|---|---|
| More than 1,000 non-file fields or more than 1,000 uploaded-file entries | The request contains too many input variables. |
| More than 64 nesting levels in a name | The request contains a field nested too deeply. |
| List index too high | The request contains an invalid list index. |
| Blocked or mixed name, or a name used with two shapes | The request contains an invalid field name. |
JSON bodies and plain objects are not counted toward the field limit. Use max() on array fields to limit lists in JSON.
Automatic type conversion
Form data and query strings only contain strings, so Holo converts values before checking rules:
field.number()turns'42'into42.field.boolean()turns'1','true','on', and'yes'intotrue, and'0','false','off', and'no'intofalse.field.date()turns any stringnew Date()can parse into aDate.- An empty string becomes
undefinedfor every type except strings, so an empty optional number input is simply omitted. - An empty file input (zero bytes, no name) becomes
undefined. - A single value for an
arrayfield becomes a one-item array.
Choose how failures are reported
| Function | On success | On failure |
|---|---|---|
validate(input, schema) | Returns typed data | Throws ValidationException (status 422) |
safeParse(input, schema) | { valid: true, data } | { valid: false, values, errors } |
parse(input, schema) | Returns typed data | Throws ValidationContractError |
Use validate() in route handlers. Holo catches the exception and sends a 422 response, so you only write the happy path.
Use safeParse() when you want to handle errors yourself:
import { safeParse } from '@holo-js/validation'
import { createPost } from './schemas/post'
const result = await safeParse(input, createPost)
if (!result.valid) {
console.log(result.errors.first('title'))
console.log(result.values.title)
} else {
console.log(result.data.title)
}On failure, result.values holds the converted input so you can show it again. On success, result.data holds the validated output.
Use parse() for internal data where a bad value is a programming error, not a user mistake. Its error is not turned into a 422 response.
Pass { bag } as the third argument to validate() to name the error bag. See Error bags.
Transform and custom rules
Use transform() to change a value and custom() or customAsync() for your own checks. Each step receives the output of the step before it.
import { field, schema } from '@holo-js/validation'
import User from '@/server/models/User'
export const signup = schema({
username: field.string()
.required()
.transform(value => value.trim().toLowerCase())
.regex(/^[a-z0-9_]+$/, 'Use letters, numbers, and underscores.')
.customAsync(async (value) => {
const existing = await User.where('username', value).first()
return !existing || 'That username is taken.'
}),
})A custom validator returns true to pass, false to fail with the default message (or the message you pass as the second argument), or a string to fail with that string. See transform for ordering details.
Use schemas with other libraries
Every schema and every field implements Standard Schema. You can pass them to any tool that accepts Standard Schema, such as SvelteKit remote functions, tRPC, or TanStack Form, without a wrapper.
import { query } from '$app/server'
import { field } from '@holo-js/validation'
import Post from '../../../server/models/Post'
export const getPost = query(field.string().required(), async (slug) => {
return await Post.where('slug', slug).firstOrFail()
})Reference
Functions
| Function | What it does |
|---|---|
field.string(), field.password(), field.number(), field.boolean(), field.date(), field.file() | Start a field of that type. See field types. |
field.array(item) | Start a list field. item is a field or an object shape. Same as arrayField(item). |
schema(shape) / defineSchema(shape) | Build a schema from an object of fields. The two names are the same function. |
validate(input, schema, options?) | Return typed data, or throw a ValidationException. options.bag names the error bag. |
safeParse(input, schema) | Return { valid, values, errors } or { valid, data, values, errors } without throwing. |
parse(input, schema) | Return typed data, or throw a ValidationContractError. Use it for internal data. |
isValidationSchema(value) | Return true if value is a schema built by schema(). |
isValidationException(value) | Narrow a live ValidationException with error bag methods. See Error Messages. |
createErrorBag(messages?) | Build an error bag from a path → messages object. See Read errors. |
ValidationException is the error that validate() throws. ValidationContractError is thrown when a schema is defined or used incorrectly. See Error Messages.
Field builder methods
Every method below returns a new builder, so you can chain them in any order. Each one is explained on Available Rules.
| Group | Methods |
|---|---|
| Presence | required, optional, nullable, default, sensitive |
| Size | min, max, size, maxSize |
| Format and choice | email, url, uuid, integer, regex, in, confirmed |
| Dates | before, after, beforeOrEqual, afterOrEqual, today, beforeToday, todayOrBefore, beforeOrToday, afterToday, todayOrAfter, afterOrToday |
| Files | image |
| Your own logic | transform, custom, customAsync |
Every builder also implements Standard Schema through its ~standard property.