Issue Paths
Structured paths make it easy to pinpoint exactly which field failed validation in nested data structures.
Understanding Issue Paths
Every validation issue includes a path array describing how to reach the failing value:
ts
const schema = v.object({
user: v.object({
email: v.string(),
}),
})
const result = schema.execute({
user: {
email: 123, // Invalid: should be string
},
})
// result.issues[0].path === ['user', 'email']
// result.issues[0].code === 'string:expected_string'Nested Objects
Paths automatically track object property access:
ts
const profileSchema = v.object({
user: v.object({
profile: v.object({
contacts: v.object({
email: v.string()
.check(v => v.includes('@')),
}),
}),
}),
})
const result = profileSchema.execute({
user: {
profile: {
contacts: {
email: 'invalid-email', // Missing @
},
},
},
})
// result.issues[0].path === ['user', 'profile', 'contacts', 'email']Array Indexes
Paths include array indexes as numbers:
ts
const schema = v.object({
items: v.array(
v.object({
id: v.string(),
price: v.number()
.min(0),
}),
),
})
const result = schema.execute({
items: [
{ id: 'a', price: 10 },
{ id: 'b', price: -5 }, // Invalid: negative price
{ id: 'c', price: 15 },
],
})
// result.issues[0].path === ['items', 1, 'price']
// Index 1 corresponds to the second itemMultiple Errors
Each error gets its own path:
ts
const schema = v.object({
users: v.array(
v.object({
email: v.string(),
age: v.number(),
}),
),
})
const result = schema.execute({
users: [
{ email: 123, age: 'twenty' }, // Two errors
{ email: 'test@example.com', age: 25 }, // Valid
{ email: true, age: null }, // Two errors
],
})
// result.issues will include:
// { path: ['users', 0, 'email'], code: 'string:expected_string' }
// { path: ['users', 0, 'age'], code: 'number:expected_number' }
// { path: ['users', 2, 'email'], code: 'string:expected_string' }
// { path: ['users', 2, 'age'], code: 'number:expected_number' }Form Field Highlighting
Map paths to form fields for UI feedback:
ts
const formSchema = v.object({
personalInfo: v.object({
firstName: v.string()
.min(1),
lastName: v.string()
.min(1),
}),
contact: v.object({
email: v.string()
.check(v => v.includes('@')),
phone: v.string()
.min(10),
}),
})
const result = await formSchema.execute(formData)
if (v.isFailure(result)) {
// Convert paths to dot-notation for field lookup
const errors = result.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
}))
// errors:
// [
// { field: 'personalInfo.firstName', message: '...' },
// { field: 'contact.email', message: '...' },
// ]
// Highlight each field
for (const { field, message } of errors) {
highlightField(field, message)
}
}API Error Responses
Include paths in HTTP error responses:
ts
const result = await requestSchema.execute(req.body)
if (v.isFailure(result)) {
return res.status(422).json({
error: 'Validation failed',
details: result.issues.map(issue => ({
path: issue.path,
field: issue.path.join('.'),
message: issue.message,
code: issue.code,
})),
})
}
// Response:
// {
// "error": "Validation failed",
// "details": [
// {
// "path": ["user", "profile", "age"],
// "field": "user.profile.age",
// "message": "Age must be at least 0",
// "code": "min:expected_min"
// }
// ]
// }Grouping Errors by Path
Aggregate multiple errors for the same field:
ts
const result = await schema.execute(data)
if (v.isFailure(result)) {
const errorsByField = new Map<string, string[]>()
for (const issue of result.issues) {
const field = issue.path.join('.')
if (!errorsByField.has(field)) {
errorsByField.set(field, [])
}
errorsByField.get(field)!.push(issue.message)
}
// errorsByField:
// Map {
// 'email' => ['Must be a valid email', 'Email already exists'],
// 'password' => ['Must be at least 8 characters']
// }
}Custom Path Manipulation
When building custom steps with implStepPlugin, manage paths manually:
ts
import type { DefineStepMethod, TStepPluginDef } from '@valchecker/internal'
import { implStepPlugin } from '@valchecker/internal'
interface PluginDef extends TStepPluginDef {
dynamicObject: DefineStepMethod</* ... */>
}
export const dynamicObject = implStepPlugin<PluginDef>({
dynamicObject: ({ utils: { addSuccessStep, success, failure, prependIssuePath } }) => {
addSuccessStep((value) => {
const issues: ExecutionIssue[] = []
for (const [key, entry] of Object.entries(value)) {
if (!isValid(entry)) {
// Prepend current key to issue path
issues.push(
prependIssuePath(
{
code: 'dynamic:invalid_entry',
path: [key], // New path segment
message: `Invalid entry: ${key}`,
payload: { key, entry },
},
[], // Base path (will be prepended by parent steps)
),
)
}
}
return issues.length > 0 ? failure(issues) : success(value)
})
},
})Best Practices
Use Paths for Field Mapping
Convert paths to your UI's field naming convention:
ts
// Convert ['user', 'profile', 'email'] to 'user_profile_email'
const fieldId = issue.path.join('_')
// Or use bracket notation for arrays:
// ['items', 0, 'name'] → 'items[0].name'
const fieldPath = issue.path
.map((segment, i) =>
typeof segment === 'number' ? `[${segment}]` : i === 0 ? segment : `.${segment}`,
)
.join('')Keep Paths Minimal
Avoid deeply nested schemas when possible. Flatter structures produce cleaner paths.
Document Path Structure
When building APIs, document the expected path format for clients:
ts
/**
* Validation errors include a `path` array:
* - Object properties: ['user', 'email']
* - Array indexes: ['items', 0, 'name']
* - Nested: ['data', 'users', 1, 'profile', 'age']
*/