Validation Errors
VeloxTS provides structured validation error responses that work seamlessly across backend and frontend.
Error Response Structure
Section titled “Error Response Structure”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
Error Codes
Section titled “Error Codes”| Code | Description |
|---|---|
invalid_type | Wrong type (e.g., string instead of number) |
invalid_string | String format invalid (email, url, uuid) |
too_small | Value below minimum (string length, number, array) |
too_big | Value above maximum |
invalid_enum_value | Value not in enum |
custom | Custom validation failed (refine) |
Nested Object Errors
Section titled “Nested Object Errors”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 Validation Errors
Section titled “Array Validation Errors”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" } ] }}Manual Validation Errors
Section titled “Manual Validation Errors”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 }); }),Multiple Field Errors
Section titled “Multiple Field Errors”// Validate multiple fields at onceconst 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);}Frontend Error Handling
Section titled “Frontend Error Handling”Type-Safe Error Handling
Section titled “Type-Safe Error Handling”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 }}Client-Side Validation
Section titled “Client-Side Validation”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 };}React Hook Form Integration
Section titled “React Hook Form Integration”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> );}import { useForm } from 'react-hook-form';import { zodResolver } from '@hookform/resolvers/zod';
const FormSchema = z.object({ name: z.string().min(1), address: z.object({ street: z.string().min(1), city: z.string().min(1), zipCode: z.string().regex(/^\d{5}$/), }),});
type FormInput = z.infer<typeof FormSchema>;
function AddressForm() { const { register, handleSubmit, formState: { errors }, } = useForm<FormInput>({ resolver: zodResolver(FormSchema), });
return ( <form onSubmit={handleSubmit(onSubmit)}> <input {...register('name')} /> {errors.name && <span>{errors.name.message}</span>}
{/* Nested fields use dot notation */} <input {...register('address.street')} /> {errors.address?.street && <span>{errors.address.street.message}</span>}
<input {...register('address.city')} /> {errors.address?.city && <span>{errors.address.city.message}</span>}
<input {...register('address.zipCode')} /> {errors.address?.zipCode && <span>{errors.address.zipCode.message}</span>} </form> );}Custom Error Messages
Section titled “Custom Error Messages”In Schema Definition
Section titled “In Schema Definition”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'),});With Refine
Section titled “With Refine”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});Error Transformation
Section titled “Error Transformation”Flatten Errors
Section titled “Flatten Errors”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" }Group by Field
Section titled “Group by Field”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"] }First Error Only
Section titled “First Error Only”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, };}Localization (i18n)
Section titled “Localization (i18n)”Using Error Maps
Section titled “Using Error Maps”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 globallyz.setErrorMap(errorMap);
// Or per-schemaconst LocalizedSchema = z.object({ email: z.string().email(),}).superRefine((data, ctx) => { // Use localized error map});With i18n Library
Section titled “With i18n Library”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 })),});HTTP Status Codes
Section titled “HTTP Status Codes”| Error Type | Status Code | When |
|---|---|---|
| Validation Error | 400 | Input validation fails |
| Authentication Error | 401 | Not logged in |
| Authorization Error | 403 | Logged in but not permitted |
| Not Found | 404 | Resource doesn’t exist |
| Conflict | 409 | Duplicate resource (email exists) |
Custom Status Codes
Section titled “Custom Status Codes”import { ValidationError, HttpError } from '@veloxts/core';
// For duplicate resources, use 409 Conflictif (await emailExists(input.email)) { throw new HttpError(409, 'Email already registered', { code: 'CONFLICT', issues: [{ path: ['email'], message: 'This email is already in use' }], });}Best Practices
Section titled “Best Practices”Security Considerations
Section titled “Security Considerations”// DON'T: Expose internal detailsthrow new ValidationError('Query failed: UNIQUE constraint on users.email');
// DO: User-friendly messagethrow new ValidationError('Validation failed', [ { path: ['email'], message: 'This email is already registered' },]);Next Steps
Section titled “Next Steps”- Schemas - Define validation schemas
- Coercion - Handle type conversion
- Error Handling - Core error patterns