Skip to content

Validation Errors

VeloxTS provides structured validation error responses that work seamlessly across backend and frontend.

When .input() validation fails, VeloxTS returns a structured error:

{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"issues": [
{
"path": ["email"],
"message": "Invalid email",
"code": "invalid_string"
},
{
"path": ["age"],
"message": "Expected number, received string",
"code": "invalid_type"
}
]
}
}

HTTP Status: 400 Bad Request

CodeDescription
invalid_typeWrong type (e.g., string instead of number)
invalid_stringString format invalid (email, url, uuid)
too_smallValue below minimum (string length, number, array)
too_bigValue above maximum
invalid_enum_valueValue not in enum
customCustom validation failed (refine)

For nested objects, the path array reflects the structure:

const AddressSchema = z.object({
street: z.string().min(1),
city: z.string().min(1),
zipCode: z.string().regex(/^\d{5}$/),
});
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
address: AddressSchema,
});

Invalid input:

{
"name": "John",
"email": "john@example.com",
"address": {
"street": "",
"city": "NYC",
"zipCode": "invalid"
}
}

Error response:

{
"error": {
"code": "VALIDATION_ERROR",
"issues": [
{
"path": ["address", "street"],
"message": "String must contain at least 1 character(s)"
},
{
"path": ["address", "zipCode"],
"message": "Invalid"
}
]
}
}

Array errors include the index in the path:

const OrderSchema = z.object({
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
})).min(1),
});

Invalid input:

{
"items": [
{ "productId": "valid-uuid", "quantity": 2 },
{ "productId": "not-a-uuid", "quantity": -1 },
{ "productId": "another-uuid", "quantity": 0 }
]
}

Error response:

{
"error": {
"code": "VALIDATION_ERROR",
"issues": [
{
"path": ["items", 1, "productId"],
"message": "Invalid uuid"
},
{
"path": ["items", 1, "quantity"],
"message": "Number must be greater than 0"
},
{
"path": ["items", 2, "quantity"],
"message": "Number must be greater than 0"
}
]
}
}

Throw validation errors for business logic validation:

import { ValidationError } from '@veloxts/core';
createUser: procedure()
.input(CreateUserSchema)
.mutation(async ({ input, ctx }) => {
// Check for existing email
const existing = await ctx.db.user.findUnique({
where: { email: input.email },
});
if (existing) {
throw new ValidationError('Email already registered', [
{ path: ['email'], message: 'This email is already in use' },
]);
}
// Check username availability
const usernameTaken = await ctx.db.user.findUnique({
where: { username: input.username },
});
if (usernameTaken) {
throw new ValidationError('Username taken', [
{ path: ['username'], message: 'This username is not available' },
]);
}
return ctx.db.user.create({ data: input });
}),
// Validate multiple fields at once
const errors: Array<{ path: string[]; message: string }> = [];
if (await emailExists(input.email)) {
errors.push({ path: ['email'], message: 'Email already registered' });
}
if (await usernameExists(input.username)) {
errors.push({ path: ['username'], message: 'Username taken' });
}
if (errors.length > 0) {
throw new ValidationError('Validation failed', errors);
}
import { api } from '@/lib/api';
interface ValidationIssue {
path: (string | number)[];
message: string;
code?: string;
}
interface ApiError {
error: {
code: string;
message: string;
issues?: ValidationIssue[];
};
}
function isValidationError(error: unknown): error is { error: ApiError['error'] } {
return (
typeof error === 'object' &&
error !== null &&
'error' in error &&
(error as ApiError).error?.code === 'VALIDATION_ERROR'
);
}
async function createUser(data: CreateUserInput) {
try {
return await api.users.createUser(data);
} catch (error) {
if (isValidationError(error)) {
// Handle validation errors
const fieldErrors = error.error.issues?.reduce((acc, issue) => {
const field = issue.path.join('.');
acc[field] = issue.message;
return acc;
}, {} as Record<string, string>);
return { success: false, errors: fieldErrors };
}
throw error; // Re-throw non-validation errors
}
}

Reuse schemas on the frontend for instant feedback:

import { CreateUserSchema } from '@shared/schemas';
function validateForm(formData: unknown) {
const result = CreateUserSchema.safeParse(formData);
if (!result.success) {
// Convert Zod errors to field errors
const errors: Record<string, string> = {};
for (const issue of result.error.issues) {
const field = issue.path.join('.');
// Only keep first error per field
if (!errors[field]) {
errors[field] = issue.message;
}
}
return { success: false, errors, data: null };
}
return { success: true, errors: {}, data: result.data };
}
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { CreateUserSchema } from '@shared/schemas';
import { z } from 'zod';
type CreateUserInput = z.infer<typeof CreateUserSchema>;
function CreateUserForm() {
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<CreateUserInput>({
resolver: zodResolver(CreateUserSchema),
});
const onSubmit = async (data: CreateUserInput) => {
try {
await api.users.createUser(data);
} catch (error) {
// Map server errors to form fields
if (isValidationError(error)) {
error.error.issues?.forEach((issue) => {
const field = issue.path.join('.') as keyof CreateUserInput;
setError(field, { message: issue.message });
});
}
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<button type="submit" disabled={isSubmitting}>
Create User
</button>
</form>
);
}
const UserSchema = z.object({
name: z.string({
required_error: 'Name is required',
invalid_type_error: 'Name must be a string',
}).min(2, 'Name must be at least 2 characters'),
email: z.string()
.email('Please enter a valid email address'),
age: z.number()
.int('Age must be a whole number')
.min(18, 'You must be at least 18 years old')
.max(120, 'Please enter a valid age'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
.regex(/[0-9]/, 'Password must contain a number'),
});
const RegistrationSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'], // Error appears on confirmPassword field
});

Convert nested paths to dot notation:

function flattenErrors(issues: ValidationIssue[]): Record<string, string> {
return issues.reduce((acc, issue) => {
const path = issue.path.join('.');
if (!acc[path]) {
acc[path] = issue.message;
}
return acc;
}, {} as Record<string, string>);
}
// { "address.street": "Required", "address.zipCode": "Invalid" }
function groupErrorsByField(issues: ValidationIssue[]): Record<string, string[]> {
return issues.reduce((acc, issue) => {
const path = issue.path.join('.');
if (!acc[path]) {
acc[path] = [];
}
acc[path].push(issue.message);
return acc;
}, {} as Record<string, string[]>);
}
// { "email": ["Invalid email", "Email already exists"] }
function getFirstError(issues: ValidationIssue[]): { field: string; message: string } | null {
if (issues.length === 0) return null;
const first = issues[0];
return {
field: first.path.join('.'),
message: first.message,
};
}
import { z } from 'zod';
const errorMap: z.ZodErrorMap = (issue, ctx) => {
// Custom messages based on error code
switch (issue.code) {
case 'invalid_type':
if (issue.expected === 'string') {
return { message: 'Ce champ doit être du texte' };
}
if (issue.expected === 'number') {
return { message: 'Ce champ doit être un nombre' };
}
break;
case 'too_small':
if (issue.type === 'string') {
return { message: `Minimum ${issue.minimum} caractères requis` };
}
break;
case 'invalid_string':
if (issue.validation === 'email') {
return { message: 'Adresse email invalide' };
}
break;
}
// Fallback to default message
return { message: ctx.defaultError };
};
// Apply globally
z.setErrorMap(errorMap);
// Or per-schema
const LocalizedSchema = z.object({
email: z.string().email(),
}).superRefine((data, ctx) => {
// Use localized error map
});
import { t } from '@/lib/i18n';
const createLocalizedSchema = (locale: string) => z.object({
name: z.string().min(1, t('validation.name.required', locale)),
email: z.string().email(t('validation.email.invalid', locale)),
age: z.number()
.min(18, t('validation.age.minimum', locale, { min: 18 })),
});
Error TypeStatus CodeWhen
Validation Error400Input validation fails
Authentication Error401Not logged in
Authorization Error403Logged in but not permitted
Not Found404Resource doesn’t exist
Conflict409Duplicate resource (email exists)
import { ValidationError, HttpError } from '@veloxts/core';
// For duplicate resources, use 409 Conflict
if (await emailExists(input.email)) {
throw new HttpError(409, 'Email already registered', {
code: 'CONFLICT',
issues: [{ path: ['email'], message: 'This email is already in use' }],
});
}
// DON'T: Expose internal details
throw new ValidationError('Query failed: UNIQUE constraint on users.email');
// DO: User-friendly message
throw new ValidationError('Validation failed', [
{ path: ['email'], message: 'This email is already registered' },
]);