API reference
Validator
What build() hands back: an object with four members, returning a plain discriminated union.
Overview
| Member | Returns |
|---|---|
validate(value, options?) | ValidationResult<T> — the original value. |
parse(value, options?) | ValidationResult<T> — the value after transforms. |
pick(path) | FieldValidator<T, ValueAtPath<T, K>> |
pickAll(paths) | SubsetValidator<T, P> |
ValidationResult
A discriminated union on valid. The success branch carries data; both branches carry issues.
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';
type User = { name: string; age: number };
const userValidator = Builder()
.use(requiredPlugin)
.use(stringMinPlugin)
.for<User>()
.v('name', b => b.string.required().min(3))
.build();
const result = userValidator.validate({ name: 'Jo', age: 36 });
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.error(issue.path + ': ' + issue.message + ' (' + issue.code + ')');
}
}import type { ValidationIssue } from '@maroonedog/luq';
type ValidationResult<T> =
| { readonly valid: true; readonly data: T; readonly issues: readonly ValidationIssue[] }
| { readonly valid: false; readonly issues: readonly ValidationIssue[] };ValidationIssue
| Field | Type | Notes |
|---|---|---|
path | string | The concrete path, with real array indices: items[1].productId. |
code | string | Defaults to the plugin's name — required, stringMin — and is overridden per rule with { code }. A code may have more than one reporter: a missing root and a missing field both report required. |
message | string | Already rendered. Override with { messageFactory }. |
severity | 'error' | 'warning' | 'info' | Never optional. Resolved once, at build time. |
causes | ValidationIssue[]? | Why a composite failed. allOf, anyOf and oneOf report which applicator failed and cannot say why; the branch failures behind it land here, each a full issue with its own code, so allOf reads down to the stringMin underneath it. Absent on an ordinary issue rather than present and undefined, so 'causes' in issue is a question worth asking. |
import { Builder } from '@maroonedog/luq';
import type { ValidationIssue } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';
type Form = { name: string; nickname: string };
const formValidator = Builder()
.use(requiredPlugin)
.use(stringMinPlugin)
.for<Form>()
.v('name', b => b.string.required().min(3))
.v('nickname', b => b.string.required().min(3))
.build();
/** Path -> first message, the shape a form layer usually wants. */
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;
}
export const fieldErrors = toFieldErrors(
formValidator.validate({ name: 'Jo', nickname: 'Al' }, { abortEarly: false }).issues
);severity, and valid results with issues
A non-error issue is reported without failing the result. So issues is worth reading on both branches, and a UI can show advisories on data it is about to accept.
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';
type Draft = { title: string; summary: string };
const draftValidator = Builder()
.use(requiredPlugin)
.use(stringMinPlugin)
.for<Draft>()
.v('title', b => b.string.required())
.v('summary', b => b.string.required().min(40, { severity: 'warning' }))
.build();
const result = draftValidator.validate({ title: 'Ship it', summary: 'Short.' });
// true — a warning does not fail the result.
export const stillValid = result.valid;
// One issue, severity 'warning', code 'stringMin'.
export const advisories = result.issues.filter(issue => issue.severity !== 'error');validate(value, options?)
Checks the value and gives it back unchanged. Transforms are not applied and the input object is never mutated.
parse(value, options?)
Runs the same checks, then applies every transform and every substituted default, and gives back the resulting object. Transforms run last — after defaults, presence, gates and checks — and they run only here.
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { transformPlugin } from '@maroonedog/luq/plugins/transform';
type SignupForm = {
email: string;
age: string; // an <input> only ever gives you a string
};
const signupValidator = Builder()
.use(requiredPlugin)
.use(transformPlugin)
.for<SignupForm>()
.v('email', b => b.string.required().transform(email => email.toLowerCase()))
.v('age', b => b.string.required().transform(age => Number.parseInt(age, 10)))
.build();
const input = { email: 'ADA@EXAMPLE.COM', age: '36' };
// { email: 'ADA@EXAMPLE.COM', age: '36' } — untouched.
const checked = signupValidator.validate(input);
// { email: 'ada@example.com', age: 36 } — transforms applied.
const parsed = signupValidator.parse(input);
export const both = [checked.valid, parsed.valid];The one execution order
default → presence → gates → checks → transforms → recursion
Chain position does not change it. .min(3).transform(f) and .transform(f).min(3) are the same declaration: min sees the value before f in both.
parse() is typed ValidationResult<T>
The runtime value is transformed, as above. The static type is still T, because build() returns Validator<T> and a transform's output type is not threaded back into the object type. If a transform changes a field's type, narrow it yourself at the boundary rather than trusting the declaration.
When a transform throws
Only parse() runs your transform functions, so only parse() can throw what one of them throws. A throw from a transform is a programmer error, not a validation issue: it is not caught, not turned into an issue, and it leaves the call site as the exception you threw. To reject a value instead, write a check — custom from @maroonedog/luq/plugins/custom catches a throw from its predicate and reports it as a failed issue.
| Same input | validate() | parse() |
|---|---|---|
| A transform that throws | valid: true, value untouched | Throws |
A custom predicate that throws | valid: false | valid: false |
A custom predicate returning false | valid: false | valid: false |
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { transformPlugin } from '@maroonedog/luq/plugins/transform';
import { customPlugin } from '@maroonedog/luq/plugins/custom';
type Payload = { id: string };
const throwingValidator = Builder()
.use(requiredPlugin)
.use(transformPlugin)
.for<Payload>()
.v('id', b =>
b.string.required().transform(id => {
if (!id.startsWith('user_')) throw new Error('not a user id');
return id.slice(5);
})
)
.build();
// { valid: true }, and `id` is still 'order_9'. validate() runs no transform.
export const checked = throwingValidator.validate({ id: 'order_9' });
// throwingValidator.parse({ id: 'order_9' }) throws that Error at the call site.
const rejectingValidator = Builder()
.use(requiredPlugin)
.use(customPlugin)
.for<Payload>()
// A check, not a transform: a rejected value is an issue, not a throw.
.v('id', b => b.string.required().custom(id => id.startsWith('user_')))
.build();
// { valid: false }, one issue at 'id'. parse() of the same input does not throw.
export const rejected = rejectingValidator.parse({ id: 'order_9' });ValidateOptions
Three keys, and the two abort flags both default to true.
| Key | Default | Effect |
|---|---|---|
abortEarly | true | Stop the whole plan after the first field that produced an issue. |
abortEarlyOnEachField | true | Stop one field after its first failing rule. |
external | — | A pre-resolved bag of outside data, read by the fromContext plugin. 1.x called this context. |
The two flags are orthogonal. Measured on one input with two failing fields, one of which breaks two rules:
import { Builder } from '@maroonedog/luq';
import type { ValidateOptions } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';
import { stringEmailPlugin } from '@maroonedog/luq/plugins/stringEmail';
type Form = { name: string; email: string };
const formValidator = Builder()
.use(requiredPlugin)
.use(stringMinPlugin)
.use(stringEmailPlugin)
.for<Form>()
.v('name', b => b.string.required().min(5))
.v('email', b => b.string.required().min(5).email())
.build();
const input = { name: 'Jo', email: 'a@b' };
const everything: ValidateOptions = {
abortEarly: false,
abortEarlyOnEachField: false,
};
// ['name'] — both options left at their defaults
export const defaults = formValidator.validate(input).issues.map(i => i.path);
// ['name', 'email'] — { abortEarly: false }
export const perField = formValidator
.validate(input, { abortEarly: false })
.issues.map(i => i.path);
// ['name', 'email', 'email'] — both false
export const perRule = formValidator.validate(input, everything).issues.map(i => i.path);Inside an array element every field is validated to completion regardless of what the caller asked for, so one bad element does not hide its siblings' problems.
pick(path)
Lifts one declared path out of the built validator, running the very same compiled rules. Use it for per-keystroke field validation without maintaining a second declaration.
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { compareFieldPlugin } from '@maroonedog/luq/plugins/compareField';
type Signup = { password: string; confirmPassword: string };
const signupValidator = Builder()
.use(requiredPlugin)
.use(compareFieldPlugin)
.for<Signup>()
.v('password', b => b.string.required())
.v('confirmPassword', b => b.string.required().compareField('password'))
.build();
const confirmField = signupValidator.pick('confirmPassword');
// A rule that reads another field needs the siblings passed in.
export const matches = confirmField.validate('hunter2', { password: 'hunter2' }).valid;
// The picked validator remembers where it came from.
export const where = confirmField.path; // 'confirmPassword'| Member | Notes |
|---|---|
path | The path this validator was picked from. |
validate(value, siblings?, options?) | siblings is a Partial<T>: rules such as compareField, requiredIf and stitch read the root through it. |
Array-element paths need no cast.
pickAll(paths)
New in 2.x. It returns a validator over exactly the named paths and hands back an object keyed by the path strings as written — flat, so 'billing.postcode' comes back as the literal key 'billing.postcode', not as a nested object. That is what a wizard step or a partial PATCH actually wants.
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();
const stepOne = registrationValidator.pickAll(['email', 'displayName']);
const outcome = stepOne.validate({
email: 'ada@example.com',
displayName: 'Ada',
billing: { postcode: '' }, // not in the picked set, so not checked
});
if (outcome.valid) {
// Keyed by the path string exactly as asked for, flat.
console.log(outcome.data.email, outcome.data.displayName);
}
export const declared = stepOne.paths; // ['email', 'displayName']Partial validation for forms
The @maroonedog/luq/form entry exports validateFields(validator, values, paths, options?) and createPartialValidator(validator, paths). The latter builds a reusable object with validate(values, options?). Both execute only the selected declarations and return { valid, issues }. They do not produce parsed output or certify the whole form.
Pass the full current form values. Select email, a container such as profile, every row with rows[*].name, or one row with rows[2].name / rows.2.name. Dependencies and ancestor constraints are not selected automatically: include confirmation fields explicitly when their source changes. On submission, call full validate() or parse().
pick() and pickAll() still execute the full plan before filtering results. Standard Schema resolvers also retain full validation. See the form guide for event integration and array-selection details.
The ./result subpath
@maroonedog/luq/result is the vocabulary for reading a result without depending on the builder, the compiler or the runtime — so a form layer or an HTTP boundary that only passes results around imports it and pulls in nothing else.
| Export | What it is |
|---|---|
unwrap(result) | The data, or a thrown ValidationFailure. A free function, not a method. |
ValidationFailure | An Error that carries .issues instead of flattening them into a string. 1.x called it LuqValidationException. |
isOk(result) | A type guard equivalent to result.valid, for use as a predicate. |
ok(value, issues?), reject(issues) | Constructors, for code that produces results of its own. |
EMPTY_ISSUES | The frozen array every issue-free result shares, so success allocates none. |
import { Builder } from '@maroonedog/luq';
import { unwrap, isOk, ValidationFailure } from '@maroonedog/luq/result';
import type { ValidationResult } from '@maroonedog/luq/result';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
type Row = { id: string };
const rowValidator = Builder()
.use(requiredPlugin)
.for<Row>()
.v('id', b => b.string.required())
.build();
/** unwrap is a FREE FUNCTION. It throws ValidationFailure, which carries issues. */
export function parseRow(input: unknown): Row {
try {
return unwrap(rowValidator.validate(input));
} catch (thrown) {
if (thrown instanceof ValidationFailure) {
throw new Error(thrown.issues.map(issue => issue.path).join(', '));
}
throw thrown;
}
}
/** isOk is the same test as `result.valid`, as a function you can pass around. */
export function keepValid(results: readonly ValidationResult<Row>[]): Row[] {
return results.filter(isOk).map(result => result.data);
}At an HTTP boundary
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 UpdateUser = { email: string; displayName: string };
// Build once, at module scope. build() is the expensive half.
const updateUserValidator = Builder()
.use(requiredPlugin)
.use(stringEmailPlugin)
.use(stringMinPlugin)
.for<UpdateUser>()
.v('email', b => b.string.required().email())
.v('displayName', b => b.string.required().min(2))
.build();
declare function persist(user: UpdateUser): Promise<UpdateUser>;
export async function handleUpdate(request: Request): Promise<Response> {
const body: unknown = await request.json();
// A form wants every field's complaint, not just the first.
const result = updateUserValidator.parse(body, { abortEarly: false });
if (!result.valid) {
return Response.json(
{
errors: result.issues.map(issue => ({
field: issue.path,
code: issue.code,
message: issue.message,
})),
},
{ status: 400 }
);
}
return Response.json({ data: await persist(result.data) });
}Porting from the 1.x Result
None of these exist any more
isValid(), isError(), unwrapOr(), unwrapOrElse(), map(), flatMap(), tap(), tapError(), data(), toPlainObject(), Result.ok(), LuqValidationException. There is no Result class to import from @maroonedog/luq.
| 1.x | Now |
|---|---|
result.isValid() | result.valid |
result.isError() | !result.valid |
result.errors | result.issues |
result.data() | result.data, on the success branch only |
result.unwrap() | unwrap(result) from @maroonedog/luq/result |
result.unwrapOr(fallback) | result.valid ? result.data : fallback |
result.map(fn) | result.valid ? fn(result.data) : result |
result.toPlainObject() | The result already is one |
ValidationError | ValidationIssue, with a non-optional severity |
ValidationOptions | ValidateOptions |
{ context } | { external } |
The rest of the 1.x mapping
The table above covers the result. Everything else is recorded beside the API it belongs to:
- Builder — removed since 1.x: the chain methods that went.
- Plugin registry — changed since 1.x: the subpath and the argument order.
- Import errors: the specifiers that resolve.
- Messages and codes: codes are plugin names now.