Examples & patterns

Working shapes, from a single field to a registration form.

Basic patterns

Nested objects

Dots go into plain objects. The container and its members are separate declarations, so you can require the object and constrain its fields independently.

typescript
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { oneOfPlugin } from '@maroonedog/luq/plugins/oneOf';
import { stringEmailPlugin } from '@maroonedog/luq/plugins/stringEmail';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';

type UserProfile = {
  user: {
    name: string;
    email: string;
  };
  settings: {
    notifications: boolean;
    theme: 'light' | 'dark';
  };
};

export const profileValidator = Builder()
  .use(requiredPlugin)
  .use(oneOfPlugin)
  .use(stringEmailPlugin)
  .use(stringMinPlugin)
  .for<UserProfile>()
  // The container and its members are separate declarations.
  .v('user', b => b.object.required())
  .v('user.name', b => b.string.required().min(2))
  .v('user.email', b => b.string.required().email())
  .v('settings', b => b.object.required())
  .v('settings.notifications', b => b.boolean.required())
  .v('settings.theme', b => b.string.required().oneOf(['light', 'dark']))
  .build();

Arrays and collections

[*] means every element. Declare the array and its elements separately: the array's own rules are length and uniqueness, the elements' rules are whatever their type allows. Chain [*] for nested arrays.

typescript
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { arrayMinLengthPlugin } from '@maroonedog/luq/plugins/arrayMinLength';
import { arrayMaxLengthPlugin } from '@maroonedog/luq/plugins/arrayMaxLength';
import { arrayUniquePlugin } from '@maroonedog/luq/plugins/arrayUnique';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';
import { numberMinPlugin } from '@maroonedog/luq/plugins/numberMin';

type Catalogue = {
  tags: string[];
  prices: number[];
  grid: number[][];
};

const catalogueValidator = Builder()
  .use(requiredPlugin)
  .use(arrayMinLengthPlugin)
  .use(arrayMaxLengthPlugin)
  .use(arrayUniquePlugin)
  .use(stringMinPlugin)
  .use(numberMinPlugin)
  .for<Catalogue>()
  // The array itself: length, uniqueness.
  .v('tags', b => b.array.required().minLength(1).maxLength(5).unique())
  // Every element of it.
  .v('tags[*]', b => b.string.required().min(2))
  .v('prices', b => b.array.required().minLength(1))
  .v('prices[*]', b => b.number.required().min(0))
  // [*] chains for nested arrays.
  .v('grid', b => b.array.required().minLength(1))
  .v('grid[*]', b => b.array.required())
  .v('grid[*][*]', b => b.number.required().min(0))
  .build();

const rejected = catalogueValidator.validate(
  { tags: ['ok', 'x'], prices: [1], grid: [[0]] },
  { abortEarly: false }
);

// ['tags[1]'] — the real index, not the wildcard.
export const failedPaths = rejected.issues.map(issue => issue.path);

Presence and conditionals

requiredIf and optionalIf are presence rules, not checks, so a field they reject runs none of the rest of its chain.

typescript
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { requiredIfPlugin } from '@maroonedog/luq/plugins/requiredIf';
import { optionalPlugin } from '@maroonedog/luq/plugins/optional';
import { nullablePlugin } from '@maroonedog/luq/plugins/nullable';
import { oneOfPlugin } from '@maroonedog/luq/plugins/oneOf';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';

type Ticket = {
  kind: 'bug' | 'other';
  reason?: string;
  closedAt: string | null;
};

const ticketValidator = Builder()
  .use(requiredPlugin)
  .use(requiredIfPlugin)
  .use(optionalPlugin)
  .use(nullablePlugin)
  .use(oneOfPlugin)
  .use(stringMinPlugin)
  .for<Ticket>()
  .v('kind', b => b.string.required().oneOf(['bug', 'other']))
  // requiredIf is a PRESENCE rule, not a check.
  .v('reason', b => b.string.requiredIf(root => root.kind === 'other').min(5))
  // .required() narrows out null as well as undefined, so say nullable.
  .v('closedAt', b => b.string.required().nullable())
  .build();

export const outcomes = [
  ticketValidator.validate({ kind: 'other', closedAt: null }),
  ticketValidator.validate({ kind: 'other', reason: 'ab', closedAt: null }),
  ticketValidator.validate({ kind: 'bug', closedAt: null }),
];
InputIssues on reason
{ kind: 'other' }one, code requiredIf — min(5) never runs
{ kind: 'other', reason: 'ab' }one, code stringMin
{ kind: 'bug' }none

closedAt: .required() narrows out null too.

Advanced patterns

Transforms and parse()

Transforms run last and only in parse(), whatever the chain order: core concepts.

typescript
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { transformPlugin } from '@maroonedog/luq/plugins/transform';
import { stringEmailPlugin } from '@maroonedog/luq/plugins/stringEmail';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';

type SignupForm = {
  email: string;
  age: string;   // an <input> only ever gives you a string
};

const signupValidator = Builder()
  .use(requiredPlugin)
  .use(transformPlugin)
  .use(stringEmailPlugin)
  .use(stringMinPlugin)
  .for<SignupForm>()
  // .email() runs on the ORIGINAL value. Transforms are always last.
  .v('email', b => b.string.required().email().transform(v => v.toLowerCase()))
  .v('age', b => b.string.required().min(1).transform(v => Number.parseInt(v, 10)))
  .build();

const input = { email: 'ADA@EXAMPLE.COM', age: '36' };

// { email: 'ADA@EXAMPLE.COM', age: '36' } — untouched.
export const checked = signupValidator.validate(input);

// { email: 'ada@example.com', age: 36 } — transformed.
export const parsed = signupValidator.parse(input);

Rules that read another field

A custom predicate takes the value only. Reading another field is compareField's job for one path, or stitch's for several.

typescript
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { compareFieldPlugin } from '@maroonedog/luq/plugins/compareField';
import { stitchPlugin } from '@maroonedog/luq/plugins/stitch';
import { numberMinPlugin } from '@maroonedog/luq/plugins/numberMin';

type Booking = {
  password: string;
  confirmPassword: string;
  minGuests: number;
  maxGuests: number;
};

export const bookingValidator = Builder()
  .use(requiredPlugin)
  .use(compareFieldPlugin)
  .use(stitchPlugin)
  .use(numberMinPlugin)
  .for<Booking>()
  .v('password', b => b.string.required())
  // One other path: compareField.
  .v('confirmPassword', b => b.string.required().compareField('password'))
  .v('minGuests', b => b.number.required().min(1))
  // Several paths, read together: stitch. The check runs exactly once.
  .v('maxGuests', b =>
    b.number.required().stitch(['minGuests'], (fields, value) => ({
      valid: typeof value === 'number' && value >= Number(fields.minGuests),
      message: 'maxGuests must not be below minGuests.',
    }))
  )
  .build();

One-off custom rules

A predicate that throws becomes a failed validation carrying the thrown message, not a crashed validate().

typescript
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { customPlugin } from '@maroonedog/luq/plugins/custom';

type Invoice = { amountCents: number };

export const invoiceValidator = Builder()
  .use(requiredPlugin)
  .use(customPlugin)
  .for<Invoice>()
  // The predicate takes the VALUE only, and runs exactly once.
  // To read another field, use compareField or stitch instead.
  .v('amountCents', b =>
    b.number.required().custom(amount => Number.isInteger(amount) && amount > 0, {
      code: 'positiveWholeAmount',
      messageFactory: context => context.path + ' must be a positive whole number of cents.',
    })
  )
  .build();

When it is worth a real plugin

If the rule wants its own chain method, message context or slot list, write a plugin instead of repeating a predicate.

Writing a plugin

Error handling

Errors for a form

This example passes { abortEarly: false }: a form wants every field's complaint at once, not the first one.

typescript
import { Builder } from '@maroonedog/luq';
import type { ValidationIssue } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { stringEmailPlugin } from '@maroonedog/luq/plugins/stringEmail';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';

type Signup = { email: string; password: string };

const signupValidator = Builder()
  .use(requiredPlugin)
  .use(stringEmailPlugin)
  .use(stringMinPlugin)
  .for<Signup>()
  .v('email', b => b.string.required().email())
  .v('password', b => b.string.required().min(8))
  .build();

function toFieldErrors(issues: readonly ValidationIssue[]): Record<string, string> {
  const errors: Record<string, string> = {};
  for (const issue of issues) {
    if (issue.severity !== 'error') continue;
    if (errors[issue.path] === undefined) errors[issue.path] = issue.message;
  }
  return errors;
}

// A form wants EVERY field's complaint, so turn abortEarly off.
export function checkSignup(input: unknown): Record<string, string> {
  const result = signupValidator.validate(input, { abortEarly: false });
  return result.valid ? {} : toFieldErrors(result.issues);
}

The 1.x result API is gone — the full mapping.

Validating part of an object

pick() gives you one declared path; pickAll() gives you several, keyed by the path strings you asked for. Both run the compiled rules from the same validator, so there is no second declaration to keep in step.

typescript
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { stringEmailPlugin } from '@maroonedog/luq/plugins/stringEmail';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';

type Registration = {
  email: string;
  displayName: string;
  billing: { postcode: string };
};

const registrationValidator = Builder()
  .use(requiredPlugin)
  .use(stringEmailPlugin)
  .use(stringMinPlugin)
  .for<Registration>()
  .v('email', b => b.string.required().email())
  .v('displayName', b => b.string.required().min(2))
  .v('billing.postcode', b => b.string.required().min(4))
  .build();

// One field, as the user types. Siblings are passed for cross-field rules.
const emailField = registrationValidator.pick('email');
export const emailLooksRight = emailField.validate('ada@example.com').valid;

// One wizard step. Returns exactly the named paths, keyed by the path string.
const stepOne = registrationValidator.pickAll(['email', 'displayName']);
const stepOneResult = stepOne.validate({
  email: 'ada@example.com',
  displayName: 'Ada',
  billing: { postcode: '' },   // not picked, so not checked
});

export const stepOneOk = stepOneResult.valid;

Performance patterns

build() is the expensive half — what it costs per shape, including the shapes where this release is slower than 1.x. Build at module scope and reuse the object: across the 5 measured shapes, one build() pays for itself after 6–207 validate() calls. The 50-element array amortises fastest, after only 6, because validate() itself costs 11.0 µs on that shape.

typescript
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { stringEmailPlugin } from '@maroonedog/luq/plugins/stringEmail';

type User = { email: string };

// Build ONCE, at module scope.
export const userValidator = Builder()
  .use(requiredPlugin)
  .use(stringEmailPlugin)
  .for<User>()
  .v('email', b => b.string.required().email())
  .build();

declare function createUser(user: User): Promise<void>;

export async function handleCreate(body: unknown): Promise<number> {
  const result = userValidator.validate(body);
  if (!result.valid) return 400;
  await createUser(result.data);
  return 201;
}

Real-world examples

Both validators below end with .strict(), a compile-time assertion that every leaf path of the type has been declared. It does not reject extra properties at run time — the fix for that.

E-commerce product

product.ts
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { optionalPlugin } from '@maroonedog/luq/plugins/optional';
import { oneOfPlugin } from '@maroonedog/luq/plugins/oneOf';
import { arrayMaxLengthPlugin } from '@maroonedog/luq/plugins/arrayMaxLength';
import { arrayUniquePlugin } from '@maroonedog/luq/plugins/arrayUnique';
import { numberMinPlugin } from '@maroonedog/luq/plugins/numberMin';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';
import { stringPatternPlugin } from '@maroonedog/luq/plugins/stringPattern';

type Product = {
  sku: string;
  name: string;
  priceCents: number;
  category: 'electronics' | 'clothing' | 'books';
  specifications: Record<string, string>;
  inStock: boolean;
  tags?: string[];
};

export const productValidator = Builder()
  .use(requiredPlugin)
  .use(optionalPlugin)
  .use(oneOfPlugin)
  .use(arrayMaxLengthPlugin)
  .use(arrayUniquePlugin)
  .use(numberMinPlugin)
  .use(stringMinPlugin)
  .use(stringPatternPlugin)
  .for<Product>()
  .v('sku', b => b.string.required().pattern(/^PROD-\d{4}$/))
  .v('name', b => b.string.required().min(2))
  .v('priceCents', b => b.number.required().min(0))
  .v('category', b =>
    b.string.required().oneOf(['electronics', 'clothing', 'books'])
  )
  .v('specifications', b => b.object.required())
  .v('inStock', b => b.boolean.required())
  .v('tags', b => b.array.optional().maxLength(10).unique())
  .v('tags[*]', b => b.string.required().min(2))
  .strict()
  .build();

User registration

A boolean that must be true uses .truthy() — there is no .equals() in the catalogue.

registration.ts
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { optionalPlugin } from '@maroonedog/luq/plugins/optional';
import { booleanTruthyPlugin } from '@maroonedog/luq/plugins/booleanTruthy';
import { compareFieldPlugin } from '@maroonedog/luq/plugins/compareField';
import { numberMinPlugin } from '@maroonedog/luq/plugins/numberMin';
import { stringEmailPlugin } from '@maroonedog/luq/plugins/stringEmail';
import { stringMaxPlugin } from '@maroonedog/luq/plugins/stringMax';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';
import { stringPatternPlugin } from '@maroonedog/luq/plugins/stringPattern';

type UserRegistration = {
  username: string;
  email: string;
  password: string;
  confirmPassword: string;
  age: number;
  acceptsTerms: boolean;
  marketingOptIn?: boolean;
};

export const registrationValidator = Builder()
  .use(requiredPlugin)
  .use(optionalPlugin)
  .use(booleanTruthyPlugin)
  .use(compareFieldPlugin)
  .use(numberMinPlugin)
  .use(stringEmailPlugin)
  .use(stringMaxPlugin)
  .use(stringMinPlugin)
  .use(stringPatternPlugin)
  .for<UserRegistration>()
  .v('username', b =>
    b.string.required().min(3).max(20).pattern(/^[a-zA-Z0-9_]+$/, {
      messageFactory: () => 'Letters, numbers and underscore only.',
    })
  )
  .v('email', b => b.string.required().email())
  .v('password', b =>
    b.string.required().min(8).pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
      messageFactory: () => 'Needs an upper case letter, a lower case letter and a digit.',
    })
  )
  // RuleOptions is always the LAST argument, after the plugin's own.
  // compareField takes an optional comparator second, so it lands third here.
  .v('confirmPassword', b =>
    b.string.required().compareField(
      'password',
      (value, target) => value === target,
      { messageFactory: () => 'The two passwords do not match.' }
    )
  )
  .v('age', b => b.number.required().min(13))
  // There is no .equals(); booleanTruthy gives .truthy().
  .v('acceptsTerms', b =>
    b.boolean.required().truthy({
      messageFactory: () => 'You must accept the terms.',
    })
  )
  .v('marketingOptIn', b => b.boolean.optional())
  .strict()
  .build();