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.
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.
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.
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 }),
];| Input | Issues 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.
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.
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().
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 pluginError handling
Errors for a form
This example passes { abortEarly: false }: a form wants every field's complaint at once, not the first one.
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.
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.
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
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.
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();