valrs

Unions

Comprehensive support for union types, discriminated unions, enums, and type modifiers to handle complex type compositions.

Union

Create a union of multiple schemas with v.union():

import { v } from 'valrs';
 
const schema = v.union([v.string(), v.number()]);
 
schema.parse('hello'); // 'hello'
schema.parse(42);      // 42
schema.parse(true);    // throws ValError
 
type StringOrNumber = v.infer<typeof schema>; // string | number

Unions try each schema in order and return the first successful match.

Complex Unions

const ResponseSchema = v.union([
  v.object({ status: v.literal('success'), data: v.string() }),
  v.object({ status: v.literal('error'), message: v.string() }),
]);
 
type Response = v.infer<typeof ResponseSchema>;
// { status: 'success'; data: string } | { status: 'error'; message: string }

Discriminated Union

For tagged unions with a shared discriminator property, use v.discriminatedUnion():

import { v } from 'valrs';
 
const EventSchema = v.discriminatedUnion('type', [
  v.object({ type: v.literal('click'), x: v.number(), y: v.number() }),
  v.object({ type: v.literal('keypress'), key: v.string() }),
  v.object({ type: v.literal('scroll'), delta: v.number() }),
]);
 
EventSchema.parse({ type: 'click', x: 100, y: 200 });     // OK
EventSchema.parse({ type: 'keypress', key: 'Enter' });    // OK
EventSchema.parse({ type: 'unknown' });                    // throws with helpful error
 
type Event = v.infer<typeof EventSchema>;
// { type: 'click'; x: number; y: number }
// | { type: 'keypress'; key: string }
// | { type: 'scroll'; delta: number }

Discriminated unions provide:

  • Better performance: Checks the discriminator first instead of trying all schemas
  • Better error messages: Reports invalid discriminator values instead of generic union errors

API Response Pattern

const ApiResponse = v.discriminatedUnion('status', [
  v.object({
    status: v.literal('success'),
    data: v.object({ id: v.string(), name: v.string() }),
  }),
  v.object({
    status: v.literal('error'),
    code: v.number(),
    message: v.string(),
  }),
  v.object({
    status: v.literal('loading'),
  }),
]);
 
function handleResponse(response: v.infer<typeof ApiResponse>) {
  switch (response.status) {
    case 'success':
      console.log(response.data.name); // TypeScript knows data exists
      break;
    case 'error':
      console.log(response.message);   // TypeScript knows message exists
      break;
    case 'loading':
      console.log('Loading...');
      break;
  }
}

Intersection

Combine multiple schemas with v.intersection():

import { v } from 'valrs';
 
const Person = v.object({ name: v.string() });
const Employee = v.object({ employeeId: v.number() });
 
const PersonEmployee = v.intersection(Person, Employee);
 
PersonEmployee.parse({ name: 'Alice', employeeId: 123 }); // OK
PersonEmployee.parse({ name: 'Alice' });                   // throws
 
type PersonEmployee = v.infer<typeof PersonEmployee>;
// { name: string } & { employeeId: number }

Extending Types

const Timestamped = v.object({
  createdAt: v.date(),
  updatedAt: v.date(),
});
 
const User = v.object({
  id: v.string(),
  email: v.string().email(),
});
 
const TimestampedUser = v.intersection(User, Timestamped);
 
type TimestampedUser = v.infer<typeof TimestampedUser>;
// { id: string; email: string; createdAt: Date; updatedAt: Date }

Literal

Create schemas for exact literal values with v.literal():

import { v } from 'valrs';
 
// String literal
const hello = v.literal('hello');
hello.parse('hello'); // 'hello'
hello.parse('world'); // throws
 
type Hello = v.infer<typeof hello>; // 'hello'
 
// Number literal
const answer = v.literal(42);
answer.parse(42); // 42
answer.parse(43); // throws
 
// Boolean literal
const yes = v.literal(true);
yes.parse(true);  // true
yes.parse(false); // throws
 
// Null literal
const nil = v.literal(null);
nil.parse(null);      // null
nil.parse(undefined); // throws

Literal Unions

const Direction = v.union([
  v.literal('north'),
  v.literal('south'),
  v.literal('east'),
  v.literal('west'),
]);
 
type Direction = v.infer<typeof Direction>; // 'north' | 'south' | 'east' | 'west'

Enum

Create string enum schemas with v.enum():

import { v } from 'valrs';
 
const Role = v.enum(['admin', 'user', 'guest']);
 
Role.parse('admin'); // 'admin'
Role.parse('user');  // 'user'
Role.parse('other'); // throws
 
type Role = v.infer<typeof Role>; // 'admin' | 'user' | 'guest'

Enum Object Access

The enum schema provides an enum property for accessing values:

const Status = v.enum(['pending', 'active', 'completed']);
 
// Access values like a TypeScript enum
Status.enum.pending;   // 'pending'
Status.enum.active;    // 'active'
Status.enum.completed; // 'completed'
 
// Use in code
function setStatus(status: v.infer<typeof Status>) {
  if (status === Status.enum.pending) {
    console.log('Waiting...');
  }
}

Options Property

Access all enum values via the options property:

const Priority = v.enum(['low', 'medium', 'high']);
 
Priority.options; // readonly ['low', 'medium', 'high']
 
// Iterate over options
Priority.options.forEach(priority => {
  console.log(priority);
});

Native Enum

Use TypeScript native enums with v.nativeEnum():

import { v } from 'valrs';
 
// Numeric enum
enum Status {
  Active,
  Inactive,
  Pending,
}
 
const StatusSchema = v.nativeEnum(Status);
 
StatusSchema.parse(Status.Active);   // 0
StatusSchema.parse(0);               // 0
StatusSchema.parse('Active');        // throws (numeric enum)
 
type StatusType = v.infer<typeof StatusSchema>; // Status
 
// String enum
enum Color {
  Red = 'red',
  Green = 'green',
  Blue = 'blue',
}
 
const ColorSchema = v.nativeEnum(Color);
 
ColorSchema.parse(Color.Red); // 'red'
ColorSchema.parse('red');     // 'red'
ColorSchema.parse('purple');  // throws

Const Enum Alternative

For const enums (which are erased at compile time), use v.enum() instead:

// Instead of const enum
const STATUS = {
  Active: 'active',
  Inactive: 'inactive',
} as const;
 
const StatusSchema = v.enum(['active', 'inactive']);

Schema Methods

.or() - Union Method

Create unions using method chaining:

const schema = v.string().or(v.number());
schema.parse('hello'); // 'hello'
schema.parse(42);      // 42
 
type T = v.infer<typeof schema>; // string | number
 
// Chain multiple
const multi = v.string().or(v.number()).or(v.boolean());
type Multi = v.infer<typeof multi>; // string | number | boolean

.and() - Intersection Method

Create intersections using method chaining:

const A = v.object({ a: v.string() });
const B = v.object({ b: v.number() });
 
const AB = A.and(B);
 
AB.parse({ a: 'hello', b: 42 }); // OK
 
type AB = v.infer<typeof AB>; // { a: string } & { b: number }

Type Modifiers

.optional()

Makes a schema accept undefined:

const schema = v.string().optional();
 
schema.parse('hello');   // 'hello'
schema.parse(undefined); // undefined
schema.parse(null);      // throws
 
type T = v.infer<typeof schema>; // string | undefined
 
// Check if optional
schema.isOptional(); // true

.nullable()

Makes a schema accept null:

const schema = v.string().nullable();
 
schema.parse('hello'); // 'hello'
schema.parse(null);    // null
schema.parse(undefined); // throws
 
type T = v.infer<typeof schema>; // string | null
 
// Check if nullable
schema.isNullable(); // true

.nullish()

Makes a schema accept both null and undefined:

const schema = v.string().nullish();
 
schema.parse('hello');   // 'hello'
schema.parse(null);      // null
schema.parse(undefined); // undefined
 
type T = v.infer<typeof schema>; // string | null | undefined
 
// Both return true
schema.isOptional(); // true
schema.isNullable(); // true

.default()

Provides a default value when input is undefined:

const schema = v.string().default('anonymous');
 
schema.parse('hello');   // 'hello'
schema.parse(undefined); // 'anonymous'
 
type T = v.infer<typeof schema>; // string (not string | undefined)

Supports factory functions for dynamic defaults:

let counter = 0;
const schema = v.string().default(() => `user-${++counter}`);
 
schema.parse(undefined); // 'user-1'
schema.parse(undefined); // 'user-2'
schema.parse('custom');  // 'custom'

.catch()

Provides a fallback value when parsing fails:

const schema = v.number().catch(0);
 
schema.parse(42);             // 42
schema.parse('not a number'); // 0
schema.parse(undefined);      // 0
schema.parse(null);           // 0
 
type T = v.infer<typeof schema>; // number

Supports factory functions:

const schema = v.number().catch(() => Math.random());
 
schema.parse('invalid'); // random number

Combining Modifiers

// Optional with default
const withDefault = v.string().optional().default('fallback');
// Input: string | undefined, Output: string
 
// Nullable with catch
const safeParse = v.number().nullable().catch(null);
// Always returns number | null, never throws
 
// Complex combination
const config = v.object({
  name: v.string(),
  port: v.number().optional().default(3000),
  debug: v.boolean().catch(false),
  apiKey: v.string().nullish(),
});
 
type Config = v.infer<typeof config>;
// {
//   name: string;
//   port: number;
//   debug: boolean;
//   apiKey: string | null | undefined;
// }

Type Inference

All union types are fully inferred:

import { v, type Infer } from 'valrs';
 
const Schema = v.discriminatedUnion('kind', [
  v.object({
    kind: v.literal('text'),
    content: v.string(),
  }),
  v.object({
    kind: v.literal('image'),
    url: v.string().url(),
    alt: v.string().optional(),
  }),
]);
 
// Using v.infer
type Message = v.infer<typeof Schema>;
 
// Or using Infer type
type Message2 = Infer<typeof Schema>;
 
// Both produce:
// { kind: 'text'; content: string }
// | { kind: 'image'; url: string; alt?: string }

Next Steps