valrs

Custom Schemas

Extend validators with refinements, transforms, and preprocessing

Custom Schemas

While valrs provides built-in primitive validators, you'll often need to add custom constraints, transform values, or preprocess input. valrs provides a Zod-compatible fluent API for these operations.

Refinements

Use .refine() to add custom validation logic that doesn't change the type:

import { v } from 'valrs';
 
// Simple refinement with message
const NonEmptyString = v.string().refine(
  s => s.length > 0,
  'Required'
);
 
NonEmptyString.parse('hello'); // 'hello'
NonEmptyString.parse('');      // throws ValError: Required
 
// Refinement with options
const Email = v.string().refine(
  s => s.includes('@') && s.includes('.'),
  { message: 'Invalid email format', path: ['email'] }
);
 
const result = Email.safeParse('user@example.com');
if (result.success) {
  console.log(result.data); // 'user@example.com'
}

Multiple Refinements

Chain refinements for multiple validation rules:

const Password = v.string()
  .refine(s => s.length >= 8, 'Must be at least 8 characters')
  .refine(s => /[A-Z]/.test(s), 'Must contain uppercase letter')
  .refine(s => /[0-9]/.test(s), 'Must contain number');
 
Password.parse('MyPass123'); // 'MyPass123'
Password.parse('weak');      // throws ValError

SuperRefine

Use .superRefine() when you need to add multiple validation issues at once:

import { v } from 'valrs';
 
const PasswordWithDetails = v.string().superRefine((val, ctx) => {
  if (val.length < 8) {
    ctx.addIssue({
      code: 'custom',
      message: 'Password must be at least 8 characters',
    });
  }
  if (!/[A-Z]/.test(val)) {
    ctx.addIssue({
      code: 'custom',
      message: 'Password must contain an uppercase letter',
    });
  }
  if (!/[0-9]/.test(val)) {
    ctx.addIssue({
      code: 'custom',
      message: 'Password must contain a number',
    });
  }
});
 
// All issues are collected, not just the first one
const result = PasswordWithDetails.safeParse('weak');
if (!result.success) {
  console.log(result.error.issues);
  // [
  //   { message: 'Password must be at least 8 characters' },
  //   { message: 'Password must contain an uppercase letter' },
  //   { message: 'Password must contain a number' }
  // ]
}

Adding Path Information

Include paths for nested validation errors:

const UserSchema = v.object({
  password: v.string(),
  confirmPassword: v.string(),
}).superRefine((val, ctx) => {
  if (val.password !== val.confirmPassword) {
    ctx.addIssue({
      code: 'custom',
      message: 'Passwords do not match',
      path: ['confirmPassword'],
    });
  }
});

Transform

Use .transform() to change the output type after validation:

import { v } from 'valrs';
 
// Parse string to number
const StringToNumber = v.string().transform(s => parseInt(s, 10));
 
StringToNumber.parse('42');  // 42 (number)
StringToNumber.parse('abc'); // NaN (validation passed, transform ran)
 
// Type inference works correctly
type Input = v.input<typeof StringToNumber>;   // string
type Output = v.output<typeof StringToNumber>; // number

Validate After Transform with Pipe

Use .pipe() to validate the transformed value:

// Parse string to number, then validate it's positive
const PositiveFromString = v.string()
  .transform(s => parseInt(s, 10))
  .pipe(v.number().positive());
 
PositiveFromString.parse('42');  // 42
PositiveFromString.parse('-5');  // throws: must be positive
PositiveFromString.parse('abc'); // throws: NaN is not positive

Common Transform Patterns

// Trim whitespace
const TrimmedString = v.string().transform(s => s.trim());
 
// Parse JSON
const JsonObject = v.string().transform(s => JSON.parse(s));
 
// Normalize email
const NormalizedEmail = v.string()
  .transform(s => s.toLowerCase().trim());
 
// Parse date
const DateFromString = v.string()
  .transform(s => new Date(s))
  .refine(d => !isNaN(d.getTime()), 'Invalid date');

Preprocess

Use v.preprocess() to transform input before validation. This is useful for coercing types:

import { v } from 'valrs';
 
// Coerce anything to string before validation
const CoercedString = v.preprocess(
  val => String(val),
  v.string()
);
 
CoercedString.parse(42);     // '42'
CoercedString.parse(true);   // 'true'
CoercedString.parse('hello'); // 'hello'
 
// Parse numbers from various formats
const FlexibleNumber = v.preprocess(
  val => {
    if (typeof val === 'string') return parseFloat(val);
    if (typeof val === 'number') return val;
    return NaN;
  },
  v.number()
);
 
FlexibleNumber.parse('3.14'); // 3.14
FlexibleNumber.parse(42);     // 42

Preprocess vs Transform

  • preprocess: Runs before validation, input type is unknown
  • transform: Runs after validation, input type is validated
// Preprocess: coerce before validation
const PreprocessedNumber = v.preprocess(
  val => Number(val),
  v.number().positive()
);
PreprocessedNumber.parse('42'); // 42 (coerced, then validated)
 
// Transform: validate then change
const TransformedNumber = v.string()
  .transform(s => Number(s))
  .pipe(v.number().positive());
TransformedNumber.parse('42'); // 42 (validated as string, transformed, validated again)

Async Validation

All refinement and transform methods support async operations:

import { v } from 'valrs';
 
// Async refinement
const UniqueUsername = v.string().refine(
  async (username) => {
    const exists = await checkUsernameExists(username);
    return !exists;
  },
  'Username already taken'
);
 
// Must use parseAsync for async schemas
const result = await UniqueUsername.parseAsync('newuser');
 
// Async transform
const UserFromId = v.string().transform(async (id) => {
  return await fetchUser(id);
});
 
const user = await UserFromId.parseAsync('user-123');

Safe Async Parsing

const result = await UniqueUsername.safeParseAsync('newuser');
if (result.success) {
  console.log(result.data);
} else {
  console.log(result.error.issues);
}

Complex Object Validation

Combine refinements with object schemas for cross-field validation:

import { v } from 'valrs';
 
const RegistrationSchema = v.object({
  email: v.string().refine(
    s => s.includes('@'),
    'Invalid email'
  ),
  password: v.string().refine(
    s => s.length >= 8,
    'Password too short'
  ),
  confirmPassword: v.string(),
  age: v.number().int().positive(),
}).superRefine((data, ctx) => {
  if (data.password !== data.confirmPassword) {
    ctx.addIssue({
      code: 'custom',
      message: 'Passwords do not match',
      path: ['confirmPassword'],
    });
  }
  if (data.age < 18) {
    ctx.addIssue({
      code: 'custom',
      message: 'Must be 18 or older to register',
      path: ['age'],
    });
  }
});
 
type Registration = v.infer<typeof RegistrationSchema>;
// { email: string; password: string; confirmPassword: string; age: number }

Best Practices

1. Use the Right Tool

// Simple boolean check → refine
v.string().refine(s => s.length > 0, 'Required');
 
// Multiple issues → superRefine
v.string().superRefine((val, ctx) => {
  // Add multiple issues
});
 
// Change the type → transform
v.string().transform(s => parseInt(s, 10));
 
// Coerce before validation → preprocess
v.preprocess(val => String(val), v.string());

2. Validate After Transform

// Bad: transform can produce invalid values
const Risky = v.string().transform(s => parseInt(s, 10));
Risky.parse('abc'); // NaN - no validation!
 
// Good: validate the transformed value
const Safe = v.string()
  .transform(s => parseInt(s, 10))
  .pipe(v.number().int());
Safe.parse('abc'); // throws: NaN is not an integer

3. Keep Validation Pure

Refinement functions should be pure (no side effects):

// Bad - has side effects
const Bad = v.string().refine(s => {
  console.log('Validating:', s); // Side effect!
  return s.length > 0;
});
 
// Good - pure function
const Good = v.string().refine(s => s.length > 0, 'Required');

4. Use Descriptive Error Messages

// Bad
v.string().refine(s => s.length > 0, 'Invalid');
 
// Good
v.string().refine(s => s.length > 0, 'Name is required');

5. Include Paths for Nested Errors

v.object({
  user: v.object({
    email: v.string(),
  }),
}).superRefine((data, ctx) => {
  if (!data.user.email.includes('@')) {
    ctx.addIssue({
      code: 'custom',
      message: 'Invalid email format',
      path: ['user', 'email'], // Include full path
    });
  }
});

Next Steps