Custom Messages
Every issue-producing step accepts a static message or message handler. Messages should remain presentation text; applications should use issue codes and payloads for programmatic behavior.
Per-step messages
const payment = v.object({
currency: v.string()
.check(value => SUPPORTED_CURRENCIES.includes(value), { message: 'We only accept USD, EUR, and GBP' }),
amount: v.number()
.isFinite()
.isAtLeast(1, { message: 'Amount must be at least $1.00' }),
})A handler receives the fully typed issue after its final path and context are known:
const product = v.object({
sku: v.string({ message: ({ payload }) =>
`Expected a string, received ${typeof payload.value}` }
),
price: v.number()
.isAtLeast(0, { message: ({ payload }) =>
`Price ${payload.value} is below ${payload.minimum}` }),
})Length constraints expose their own explicit payload:
const username = v.string()
.isLengthAtLeast(3, { message: ({ payload }) =>
`Expected at least ${payload.minimumLength} characters; received ${payload.length}` })Global message resolver
import { allSteps, createValchecker } from 'valchecker'
const v = createValchecker({
steps: allSteps,
message: ({ code, payload }) => {
switch (code) {
case 'string:expected_string':
return 'This field must be text'
case 'number:expected_number':
return 'This field must be a number'
case 'isFinite:expected_finite':
return 'This field must be a finite number'
case 'isAtLeast:expected_at_least':
return `Minimum value is ${payload.minimum}`
case 'isAtMost:expected_at_most':
return `Maximum value is ${payload.maximum}`
default:
return 'Validation failed'
}
},
})A message may also be a map keyed by issue code:
const v = createValchecker({
steps: allSteps,
message: {
'string:expected_string': () => 'This field must be text',
'isAtLeast:expected_at_least': ({ payload }) => `Minimum value is ${payload.minimum}`,
},
})TypeScript note
Map-form handler params are contextually typed only when the issue codes are a concrete union. When the union flows through an unresolved generic — as it does through createValchecker's inference and through structural step options such as object({...}, { message }) — the param falls back to implicit any. Either annotate the param explicitly (({ payload }: { payload: { value: unknown } }) => ...) or use the function form (message: (issue) => ...), which stays fully typed even through generics.
Internationalization
Use issue codes as stable translation keys and payload values as interpolation data:
const translations = {
en: {
'string:expected_string': 'This field must be text',
'isLengthAtLeast:expected_length_at_least': 'Enter at least {minimumLength} characters',
'check:failed': 'Validation failed',
},
fr: {
'string:expected_string': 'Ce champ doit être du texte',
'isLengthAtLeast:expected_length_at_least': 'Saisissez au moins {minimumLength} caractères',
'check:failed': 'La validation a échoué',
},
}
function translate(code: string, payload: Record<string, unknown>, locale: 'en' | 'fr') {
const template = translations[locale][code] ?? translations.en[code]
return template?.replace(/\{(\w+)\}/g, (_, key) => String(payload[key] ?? ''))
?? 'Validation failed'
}const localized = createValchecker({
steps: allSteps,
message: ({ code, payload }) => translate(code, payload, 'fr'),
})Message priority
Messages resolve once, after nested schemas have completed the issue path, in this order:
- originating per-step message,
- nearest enclosing structure message,
- remaining enclosing structure messages,
- originating Valchecker instance global resolver,
- originating built-in default,
"Invalid value.".
const v = createValchecker({
steps: allSteps,
message: () => 'Global message',
})
const schema = v.string()
.isLengthAtLeast(3, { message: 'Per-step message' })The per-step message takes precedence. Returning null or undefined continues to the next source.
HTTP responses
Expose structured fields rather than only a concatenated message:
const result = await userSchema.execute(requestBody)
if (v.isFailure(result)) {
return Response.json({
error: 'Validation failed',
details: result.issues.map(issue => ({
path: issue.path,
context: issue.context,
code: issue.code,
category: issue.category,
message: issue.message,
payload: issue.payload,
})),
}, { status: 422 })
}Clients may localize code independently while logs retain the original structured payload.
Form errors
const form = v.object({
email: v.string()
.check(value => value.includes('@'), { message: 'Please enter a valid email' }),
password: v.string()
.isLengthAtLeast(8, { message: 'Password must be at least 8 characters' }),
confirmation: v.string(),
})
.check(value => value.password === value.confirmation, { message: 'Passwords must match' })Use issue.path to map nested failures to fields. A root-level cross-field check() issue has an empty path unless a custom step supplies a different path.
Guidance
- Keep user-facing messages actionable and product-specific.
- Keep issue codes stable and machine-readable.
- Do not parse numbers or field names back out of message strings.
- Include sensitive input values in messages or logs only when appropriate.
- Test both default and custom message paths when adding a step.
Handler failures
Message handlers execute inside Valchecker's public execution boundary. If a step, enclosing structure, global, or default handler throws, Valchecker returns a core:message_exception issue instead of throwing the message error. Its payload identifies the source, preserves the unresolved issue snapshot, and carries the original thrown value.
For global localization of domain-specific issues, declare them through a registered custom plugin's Meta.SelfIssue. A dynamic issue added only by a later check() call cannot retroactively extend the issue universe of an already-created global handler.