Getting started
Luq takes a type you already wrote and lets you declare rules against its field paths. Every rule you can call is a plugin you imported by name.
Type-first
.for<T>() names your own type.
Plugins are imports
An unimported plugin's method does not typecheck. The builder alone gzips to 8,799 B.
One result shape
A discriminated union on valid.
Installation
Version 2.9.0.
npm install @maroonedog/luqyarn add @maroonedog/luqpnpm add @maroonedog/luqTypeScript configuration
strict is not required. Every check below is a compile error either way — a slot that does not match the field, a missing [*], a path the type does not declare, a method whose plugin you did not import. Turning strict on is still worth doing. node16 and bundler resolution both work.
{
"compilerOptions": {
"strict": true,
"target": "ES2020",
"moduleResolution": "bundler" // or "node16"
}
}The five calls
Every validator is the same five steps. .use() comes before .for(); nothing else has an order that matters.
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';
import { stringEmailPlugin } from '@maroonedog/luq/plugins/stringEmail';
import { numberMinPlugin } from '@maroonedog/luq/plugins/numberMin';
// The type you already have. Nothing is rewritten as a schema.
type User = {
name: string;
email: string;
age: number;
};
const userValidator = Builder() // 1. start — a function, not a constructor
.use(requiredPlugin) // 2. put plugins in the bag
.use(stringMinPlugin)
.use(stringEmailPlugin)
.use(numberMinPlugin)
.for<User>() // 3. name the type being validated
.v('name', b => b.string.required().min(2)) // 4. declare each field
.v('email', b => b.string.required().email())
.v('age', b => b.number.required().min(18))
.build(); // 5. compile once, keep the validator
const result = userValidator.validate({
name: 'John Doe',
email: 'john@example.com',
age: 25,
});
if (result.valid) {
// `data` exists only on this branch — no cast, no non-null assertion.
console.log(result.data.name);
} else {
for (const issue of result.issues) {
console.log(`${issue.path}: ${issue.message} (${issue.code})`);
}
}Build once, keep the validator
build() costs 13.6–697.3 µs depending on the shape. validate() is sub-microsecond on 3 of the 5 measured shapes, and costs 3.2 µs on a Draft-07 document and 11.0 µs on a 50-element array. Build at module scope, not per request — the full figures.
What build() returns
An object with four members. It is not callable.
| Member | What it gives back |
|---|---|
| validate(value, options?) | ValidationResult<T> holding the original value |
| parse(value, options?) | ValidationResult<TParsed> holding the value after transforms |
| pick(path) | a single-field validator for one declared path |
| pickAll(paths) | a validator returning exactly those paths, keyed by the path string |
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';
type Profile = { name: string; bio: string };
const profileValidator = Builder()
.use(requiredPlugin)
.use(stringMinPlugin)
.for<Profile>()
.v('name', b => b.string.required().min(2))
.v('bio', b => b.string.required())
.build();
// The whole object, original values.
const whole = profileValidator.validate({ name: 'Jo', bio: 'b' });
// The whole object, transforms applied.
const parsed = profileValidator.parse({ name: 'Jo', bio: 'b' });
// One declared field, on its own.
const oneField = profileValidator.pick('name').validate('Jo');
// Several fields, returned keyed by the path you asked for.
const subset = profileValidator
.pickAll(['name', 'bio'])
.validate({ name: 'Jo', bio: 'b' });Reading the result
A discriminated union on valid — every member is documented on the validator API.
import { Builder } from '@maroonedog/luq';
import type { ValidationIssue } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
type Note = { title: string; text: string };
const noteValidator = Builder()
.use(requiredPlugin)
.for<Note>()
.v('title', b => b.string.required())
.v('text', b => b.string.required())
.build();
const result = noteValidator.validate({ title: 'Hi', text: '' });
export function describe(): string {
if (result.valid) {
// Reachable only here. `data` is typed as Note.
return result.data.title;
}
// Every issue is { path, code, message, severity }.
return result.issues
.map((issue: ValidationIssue) => `${issue.path} ${issue.code} ${issue.severity}`)
.join(', ');
}data exists only on the success branch, so narrowing on valid replaces every cast. The default code is the plugin's own name — required, stringMin.
Prefer exceptions? @maroonedog/luq/result gives you unwrap() — see the validator API.
validate() vs parse()
validate() never changes your value; parse() applies .transform() rules. Why transforms run last, and what that does to the chain's type: core concepts.
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { transformPlugin } from '@maroonedog/luq/plugins/transform';
type Signup = { email: string };
const signupValidator = Builder()
.use(requiredPlugin)
.use(transformPlugin)
.for<Signup>()
.v('email', b => b.string.required().transform(value => value.trim()))
.build();
// -> " a@b.com " — validate() never changes your value.
const kept = signupValidator.validate({ email: ' a@b.com ' });
// -> "a@b.com" — parse() applies transforms, and only to a field whose
// checks all passed.
const trimmed = signupValidator.parse({ email: ' a@b.com ' });Options
abortEarly, abortEarlyOnEachField, external — in full on the validator API.
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';
type NameForm = { first: string; last: string };
const formValidator = Builder()
.use(requiredPlugin)
.use(stringMinPlugin)
.for<NameForm>()
.v('first', b => b.string.required().min(2))
.v('last', b => b.string.required().min(2))
.build();
const bad = { first: 'a', last: 'b' };
// Default: stop at the first field that fails -> 1 issue.
const short = formValidator.validate(bad);
// Every field, one representative issue each -> the form UX combination.
const perField = formValidator.validate(bad, { abortEarly: false });
// Every violation of every field.
const everything = formValidator.validate(bad, {
abortEarly: false,
abortEarlyOnEachField: false,
});abortEarly defaults to true
Both flags default to true. Pass { abortEarly: false } to collect every issue.
Defaults
import { Builder } from '@maroonedog/luq';
import { optionalPlugin } from '@maroonedog/luq/plugins/optional';
type Settings = { language?: string };
const settingsValidator = Builder()
.use(optionalPlugin)
.for<Settings>()
// The third argument of .v() is a field configuration, never a rule.
.v('language', b => b.string.optional(), { default: 'en' })
.build();
// The default is substituted before any rule looks at the value, so validate()
// and parse() judge the same thing — but only parse() writes it back.
settingsValidator.parse({}); // { valid: true, data: { language: 'en' }, issues: [] }
settingsValidator.validate({}); // { valid: true, data: {}, issues: [] }applyDefaultToNull defaults to true.
Normalising before the rules run
A form gives you a string in a number field and spaces around a name. normalize sits beside default in the same options object and tidies the value before anything judges it, so validate() and parse() never disagree about what they looked at.
import { Builder } from "@maroonedog/luq";
import { requiredPlugin } from "@maroonedog/luq/plugins/required";
import { stringEmailPlugin } from "@maroonedog/luq/plugins/stringEmail";
import { numberMinPlugin } from "@maroonedog/luq/plugins/numberMin";
type Signup = { email: string; age: number };
const signupValidator = Builder()
.use(requiredPlugin)
.use(stringEmailPlugin)
.use(numberMinPlugin)
.for<Signup>()
// A form posts " Ada@Example.com " — judge the tidied value.
.v("email", (b) => b.string.required().email(), {
normalize: (value) =>
typeof value === "string" ? value.trim().toLowerCase() : value,
})
// A number input still hands over a string.
.v("age", (b) => b.number.required().min(18), {
normalize: (value) => (value === "" ? value : Number(value)),
})
.build();
// true — the rules saw "ada@example.com" and 30.
// validate() and parse() both take unknown, so the untidied shape needs no cast.
signupValidator.validate({ email: " Ada@Example.com ", age: "30" }).valid;
// parse() writes the tidied values back; validate() leaves your object alone.
signupValidator.parse({ email: " Ada@Example.com ", age: "30" });It takes and returns unknown on purpose: the value has not been validated yet, and "42" → 42 is the point. It is never called with undefined or null — so (v) => String(v).trim() cannot turn a missing field into the string "undefined" and sneak it past required.
Nested fields and arrays
Dots for nested fields, [*] for array elements; you declare [*] and the issue comes back with the real index. Every form: the path grammar.
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';
import { arrayMinLengthPlugin } from '@maroonedog/luq/plugins/arrayMinLength';
type Order = {
customer: { name: string; address: { country: string } };
lines: { sku: string; tags: string[] }[];
};
const orderValidator = Builder()
.use(requiredPlugin)
.use(stringMinPlugin)
.use(arrayMinLengthPlugin)
.for<Order>()
.v('customer.name', b => b.string.required().min(2))
.v('customer.address.country', b => b.string.required().min(2))
.v('lines', b => b.array.required().minLength(1))
.v('lines[*].sku', b => b.string.required().min(4))
.v('lines[*].tags[*]', b => b.string.required().min(1))
.build();
const rejected = orderValidator.validate(
{
customer: { name: 'Acme', address: { country: 'JP' } },
lines: [{ sku: 'PROD', tags: ['a'] }, { sku: 'X', tags: ['b'] }],
},
{ abortEarly: false }
);
// -> ["lines[1].sku"], never "lines[*].sku"
const failedPaths = rejected.issues.map(issue => issue.path);Messages, codes and severity
Every plugin method takes the same trailing options object: { code?, messageFactory?, severity? }. There is no message: string option anywhere — a constant message is { messageFactory: () => "..." }.
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';
type Account = { handle: string };
const accountValidator = Builder()
.use(requiredPlugin)
.use(stringMinPlugin)
.for<Account>()
.v('handle', b =>
b.string.required().min(3, {
code: 'handleTooShort',
severity: 'warning',
// The context carries this plugin's own members, typed.
messageFactory: context =>
`${context.path}: need ${String(context.min)}, got ${String(context.actual)}`,
})
)
.build();
accountValidator.validate({ handle: 'ab' });
// {
// valid: true,
// data: { handle: 'ab' },
// issues: [{ path: 'handle', code: 'handleTooShort',
// message: 'handle: need 3, got 2', severity: 'warning' }]
// }A non-error severity is reported without failing the result, so issues is worth reading on the success branch too.
Importing plugins
Every chain method is a plugin you imported by name — why that typechecks. The plugin catalogue lists all 77, with subpath, chain method and slots.
// The supported route: one subpath per plugin.
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';
// The barrel also resolves, and costs the same when a bundler can tree-shake.
// The measured figures are in the prose below this block.
import { stringEmailPlugin } from '@maroonedog/luq/plugins';The root @maroonedog/luq entry exports no plugins at all, which is why the bare builder gzips to 8,799 B and adding the first one costs 136 B on top of it. The barrel is not a tax on that: the measurement.
Standard Schema
Luq implements Standard Schema v1 on the @maroonedog/luq/standard-schema subpath — a separate specifier, so a validator that never reaches a boundary does not pay for it.
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';
import { toStandardSchema } from '@maroonedog/luq/standard-schema';
type Account = { handle: string };
const standard = toStandardSchema(
Builder()
.use(requiredPlugin)
.use(stringMinPlugin)
.for<Account>()
.v('handle', (b) => b.string.required().min(2))
.build()
);
// Still the Validator — validate, parse, pick and pickAll are all there —
// and now also a Standard Schema v1, so tRPC / TanStack Form / Hono / t3-env
// take it wherever they take a zod schema.
const outcome = standard['~standard'].validate({ handle: 'j' });
if (outcome.issues === undefined) {
console.error(outcome.value.handle);
} else {
for (const issue of outcome.issues) {
console.error(issue.message, issue.path);
}
}Standard Schema has the rest: the three decisions the spec leaves open, and the call sites it plugs into.
Next steps
Core concepts
Slots, presence, field paths, and the order rules run in
Examples & patterns
Cross-field rules, form errors, and two whole validators
Writing a plugin
definePlugin, the markers, and the out contract
Plugin catalog
Every subpath, chain method and slot
Validator API
The four members of build(), and the 1.x Result mapping