Skip to content

Error Messages ​

When validation fails, Holo collects messages for each field into an error bag and sends them to the client as a 422 response. This page covers the response format, custom messages, reading errors, and adding your own errors from business logic.

The 422 response ​

When validate() throws inside a route handler, Holo responds with status 422 and this JSON body:

json
{
  "ok": false,
  "status": 422,
  "valid": false,
  "message": "email: This field must be a valid email address.",
  "bag": "default",
  "values": {
    "email": "ava@example",
    "remember": true
  },
  "errors": {
    "email": ["This field must be a valid email address."],
    "address.city": ["This field is required."]
  }
}
  • errors maps each field path to its messages. Nested fields use dots (address.city) and array items use their index (tags.0).
  • values echoes the submitted input so a form can show it again. Uploaded files, passwords, and sensitive fields are left out, including inside nested objects and lists.
  • message is the first error, prefixed with its field path.
  • bag names the error bag. It is default unless you choose another one.

useForm() reads this shape automatically, so you rarely parse it by hand.

How each framework responds ​

You don't need to catch ValidationException yourself. Holo handles it in each framework:

FrameworkWhere you throwWhat the client gets
Next.jsRoute handler (app/**/route.ts)422 JSON response
Next.jsServer action called by useForm()The action returns the failure payload, and useForm() shows the field errors
Next.jsServer action passed to <form action>The page renders again with the errors for useValidationErrors()
Nuxtserver/api/** or a request that doesn't accept HTML422 JSON response
Nuxtserver/routes/** from a native HTML form post303 redirect back to the form, with errors flashed for useValidationErrors()
SvelteKit+server.ts422 JSON response
SvelteKitForm action in +page.server.tsErrors flashed for useForm() and useValidationErrors() on the page, passed as pageData from the root layout load

Default messages ​

These are the messages you get when you don't pass your own:

RuleDefault message
required()This field is required.
string() / password()This field must be text.
number()This field must be a number.
boolean()This field must be true or false.
date()This field must be a valid date.
file()The selected file must be a file.
array()This field must be a list.
min(n)This field must be at least n characters. / must contain at least n items. / must be n or greater.
max(n)This field must be n characters or fewer. / must contain n items or fewer. / must be n or less.
size(n)This field must be exactly n characters. / must contain exactly n items. / must be exactly n.
max() / maxSize() on filesThe selected file must be 2 MB or smaller (bytes if you pass a number).
size() on filesThe selected file must be exactly 2 MB (or the byte count you pass).
email()This field must be a valid email address.
url()This field must be a valid URL.
uuid()This field must be a valid UUID.
integer()This field must be an integer.
regex()This field format is invalid.
in()This field must be one of the allowed values.
confirmed()This field does not match its confirmation.
before(date)This field must be before <date>, written in ISO format (for example 2030-01-01T00:00:00.000Z).
after(date)This field must be after <date>, in ISO format.
beforeOrEqual(date)This field must be before or equal to <date>, in ISO format.
afterOrEqual(date)This field must be after or equal to <date>, in ISO format.
today()This field must be today.
beforeToday()This field must be before today.
afterToday()This field must be after today.
todayOrBefore() / beforeOrToday()This field must be today or before.
todayOrAfter() / afterOrToday()This field must be today or after.
image()The selected file must be an image.
imageAspectRatio(ratios)The selected image must have an allowed aspect ratio.
custom() returning falseValidation failed.

Custom messages ​

Pass a message as the last argument to any rule:

ts
import { field, schema } from '@holo-js/validation'

export const registerUser = schema({
  email: field.string()
    .required('Email is required.')
    .email('Enter a valid email address.'),
  password: field.password()
    .required('Choose a password.')
    .min(8, 'Password must be at least 8 characters.'),
  birthday: field.date()
    .beforeToday('Your birthday must be in the past.'),
  avatar: field.file()
    .optional()
    .image('Upload a PNG, JPEG, or WebP image.')
    .maxSize('2mb', 'Images must be 2 MB or smaller.'),
})

custom() and customAsync() can also return the message directly:

ts
field.string().custom(value => value !== 'admin' || 'That name is reserved.')

Read errors ​

safeParse() returns an error bag in result.errors. A thrown ValidationException has the same bag on error.errors.

ts
import { safeParse } from '@holo-js/validation'
import { checkout } from './schemas/checkout'

const result = await safeParse(input, checkout)

if (!result.valid) {
  result.errors.has('email')
  result.errors.first('email')
  result.errors.get('address.city')
  result.errors.flatten()
}
MethodReturns
has(path)true if the field has at least one error
first(path)The first message, or undefined
get(path)All messages for the field (an empty array if none)
flatten()An object of path → messages
toJSON()Same as flatten(), used by JSON.stringify()

You can also read errors as typed properties that follow the schema shape:

ts
result.errors.email
result.errors.address?.city

Errors that don't belong to one field, such as a rate-limit failure, use the path _root:

ts
result.errors.first('_root')

Add errors from your own logic ​

Some checks can only happen after validation, such as "this slug is already taken" or "the upload failed". Throw ValidationException.withMessages() to report them as field errors with the same 422 response:

ts
import { validate, ValidationException } 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)

  if (await Post.where('slug', data.slug).first()) {
    throw ValidationException.withMessages({
      slug: ['This slug is already in use.'],
    })
  }

  return Response.json(await Post.create(data), { status: 201 })
}

You don't need to catch a ValidationException to send it back: Holo turns it into the 422 response or the page's errors, as the table above shows. When your own code must handle it, for example to answer a failed login with a 401 instead, narrow it with isValidationException(). error.errors is its error bag, and error.toJSON() is the 422 response body:

ts
import { isValidationException } from '@holo-js/validation'

try {
  await auth.guard('api').login(input)
} catch (error) {
  if (isValidationException(error)) {
    return Response.json({ ok: false, status: 401, message: 'Invalid credentials.' }, { status: 401 })
  }

  throw error
}

Error bags ​

A page with two forms (for example, "update profile" and "change password") needs to know which form an error belongs to. Give each form its own bag name:

ts
import { validate } from '@holo-js/validation'
import { changePassword } from './schemas/account'

const data = await validate(request, changePassword, { bag: 'password' })
ts
import { ValidationException } from '@holo-js/validation'

throw ValidationException.withMessages({
  currentPassword: ['Your current password is incorrect.'],
}, {
  bag: 'password',
})

Read a named bag on the page with useValidationErrors('password') in Nuxt or SvelteKit. In SvelteKit, pass the page data as useValidationErrors('password', { pageData: data }). See Framework Recipes.

Exception reference ​

validate() throws a ValidationException. Its properties:

MemberDescription
messageThe first error, prefixed with its field path (the same text as the message in the 422 response).
statusAlways 422, unless a security check set another status such as 429.
bagThe error bag name. default unless you passed bag.
errorsThe error bag with has(), get(), first(), flatten(), and toJSON().
valuesThe submitted values after type conversion. Uploaded files are left out.
digestAn encoded copy of the error payload. The Next.js adapter reads it to render errors. Don't parse it yourself.
toJSON()The 422 response body as a plain object.
ValidationException.withMessages(messages, options?)Create an exception from a path → messages object. options.bag names the bag.

ValidationContractError is different: it means the schema itself is wrong. Holo throws it when you define or use a rule incorrectly, such as an empty in() list, a regex() that isn't a RegExp, or a max() string on a non-file field. It is not turned into a 422 response, so fix the code instead of catching it.

createErrorBag(messages?) builds the same error bag object from a path → messages object. Holo uses it internally, and you can use it when you need an error bag outside a validation run.

Next steps ​

Released under the MIT License.