Skip to content

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 ​

ts
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(),
})
ts
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.status

data 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:

ts
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.

ts
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 writeAccepts undefinedAccepts nullRejects empty strings and []
field.string()NoNoNo
field.string().required()NoNoYes
field.string().optional()YesNoNo
field.string().nullable()NoYesNo
field.string().optional().nullable()YesYesNo
field.boolean().default(false)Yes, becomes falseNoNo

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.

ts
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:

RequestData read from
GET or HEADQuery string
application/jsonJSON body
multipart/form-data or application/x-www-form-urlencodedForm body
Empty bodyTreated 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.city and address[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] with tags[0] works, but tags[5] on its own fails.
  • Names that use __proto__, constructor, or prototype fail. So do brackets that aren't closed or are closed without an opening bracket, such as tags[0 or tags].
  • A name must keep one shape. tags[name] can't be combined with tags[], tags[0], or tags=x, and numbered indexes (tags[0]) can't be combined with tags[] or repeated names, because the order of the fields would change the result. Repeated names and tags[] can be mixed, such as tags=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:

ProblemMessage
More than 1,000 non-file fields or more than 1,000 uploaded-file entriesThe request contains too many input variables.
More than 64 nesting levels in a nameThe request contains a field nested too deeply.
List index too highThe request contains an invalid list index.
Blocked or mixed name, or a name used with two shapesThe 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' into 42.
  • field.boolean() turns '1', 'true', 'on', and 'yes' into true, and '0', 'false', 'off', and 'no' into false.
  • field.date() turns any string new Date() can parse into a Date.
  • An empty string becomes undefined for 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 array field becomes a one-item array.

Choose how failures are reported ​

FunctionOn successOn failure
validate(input, schema)Returns typed dataThrows ValidationException (status 422)
safeParse(input, schema){ valid: true, data }{ valid: false, values, errors }
parse(input, schema)Returns typed dataThrows 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:

ts
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.

ts
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.

ts
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 ​

FunctionWhat 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.

GroupMethods
Presencerequired, optional, nullable, default, sensitive
Sizemin, max, size, maxSize
Format and choiceemail, url, uuid, integer, regex, in, confirmed
Datesbefore, after, beforeOrEqual, afterOrEqual, today, beforeToday, todayOrBefore, beforeOrToday, afterToday, todayOrAfter, afterOrToday
Filesimage
Your own logictransform, custom, customAsync

Every builder also implements Standard Schema through its ~standard property.

Next steps ​

Released under the MIT License.