Guide
Troubleshooting
The errors people actually hit, with the code that causes them beside the code that fixes them. Blocks marked does not compile are kept broken on purpose.
Import errors
ERR_PACKAGE_PATH_NOT_EXPORTED, or "cannot find module"
Cause. A 1.x specifier. The package publishes 89 export keys — the fixed ones are in the table below, and the rest are one per plugin under ./plugins/. Nothing else resolves.
// Does not compile. None of these specifiers exist in the exports map.
import { Builder } from '@maroonedog/luq/core';
import { requiredPlugin } from '@maroonedog/luq/plugin';
import { createPluginRegistry } from '@maroonedog/luq/core/registry';
import { createAsyncContext } from '@maroonedog/luq/async.experimental';
export const used = [Builder, requiredPlugin, createPluginRegistry, createAsyncContext];import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { createPluginRegistry } from '@maroonedog/luq/field-rule';
import { createAsyncContext } from '@maroonedog/luq/async';
import { unwrap } from '@maroonedog/luq/result';
import { definePlugin } from '@maroonedog/luq/plugin-kit';
export const used = [
Builder,
requiredPlugin,
createPluginRegistry,
createAsyncContext,
unwrap,
definePlugin,
];| Key | What is behind it |
|---|---|
@maroonedog/luq | Builder, the result vocabulary, the shared types. No plugins. |
…/plugins/<name> | One plugin each, camelCase. 77 of them. |
…/plugins | The convenience barrel. |
…/result | unwrap, isOk, ok, reject, ValidationFailure. |
…/field-rule | createPluginRegistry, createFieldRule, useField. |
…/async | Promise resolution. Not async.experimental. |
…/plugin-kit | The plugin-author surface. |
…/package.json | For tooling that reads it. |
A plugin imported from the package root is undefined
Cause. 1.x's dist/index.d.ts declared 57 plugin exports its dist/index.js did not have, so the import typechecked and gave you undefined at run time. The root now exports no plugins at all — import them by subpath.
// Does not compile. The root subpath exports NO plugins, deliberately.
import { Builder, requiredPlugin } from '@maroonedog/luq';
export const used = [Builder, requiredPlugin];Compile errors
"Property '.email' does not exist" — a chain method is missing
Cause. The plugin contributing that method is not in the bag. The bag is a type, so an unimported plugin's method does not typecheck.
// Does not compile: .email() is not on the chain, because the plugin
// that contributes it was never put in the bag.
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
type User = { email: string };
export const userValidator = Builder()
.use(requiredPlugin)
.for<User>()
.v('email', b => b.string.required().email())
.build();import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { stringEmailPlugin } from '@maroonedog/luq/plugins/stringEmail';
type User = { email: string };
export const userValidator = Builder()
.use(requiredPlugin)
.use(stringEmailPlugin) // one plugin per use(); there is no variadic form
.for<User>()
.v('email', b => b.string.required().email())
.build();Also check the method's name: it is often not the plugin's. stringMinPlugin gives .min(), unionGuardPlugin gives .guard(), tupleBuilderPlugin gives .builder(). The plugin catalogue lists every method.
The slot does not match the field's type
Cause. 1.x let this through in silence; it is a compile error now. Use b.any when you genuinely mean "whatever this is".
// Does not compile: 'age' is a number, so b.string is not offered.
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
type Person = { age: number };
export const personValidator = Builder()
.use(requiredPlugin)
.for<Person>()
.v('age', b => b.string.required())
.build();The field path is not accepted
Cause. One rule, not four special cases: array members are never enumerated, and built-in objects are opaque.
// Does not compile. Four path forms 1.x accepted, and why each is gone.
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
type Model = { items: { name: string }[]; tags: string[]; when: Date };
export const modelValidator = Builder()
.use(requiredPlugin)
.for<Model>()
.v('items.name', b => b.string.required()) // returned the raw array in 1.x
.v('items.*.name', b => b.string.required()) // never generated by the type layer
.v('items[0].name', b => b.string.required()) // a fixed index was never supported
.v('tags.length', b => b.number.required()) // that is the array's own property
.v('when.getTime', b => b.any.required()) // no path enters a Date
.build();import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { arrayMinLengthPlugin } from '@maroonedog/luq/plugins/arrayMinLength';
type Model = { items: { name: string }[]; tags: string[]; when: Date };
export const modelValidator = Builder()
.use(requiredPlugin)
.use(arrayMinLengthPlugin)
.for<Model>()
.v('items[*].name', b => b.string.required())
.v('tags', b => b.array.required().minLength(1))
.v('when', b => b.date.required())
.build();Path generation also counts down a depth budget of 6, so a self-referential model yields a finite path union instead of a "type instantiation is excessively deep" error. Reach deeper with the objectRecursively plugin.
tsc exits with RangeError: Maximum call stack size exceeded
Cause. One builder carrying several hundred .v() calls in a single expression. Each call returns a type carrying every path declared before it, so the chain's type grows with the chain, and past a few hundred links the compiler runs out of stack descending it.
What it looks like. Not a diagnostic — tsc does not report an error and exit non-zero the way it does for an ordinary type problem. It throws, prints a JavaScript stack from inside typescript/lib/_tsc.js, and dies. An editor's language server usually just stops answering for the file.
Where the edge is. Measured against the published declaration files on TypeScript 5.8.3: 530 chained .v() calls compile and 531 crash. The edge moves with the TypeScript version and with the rules on each field.
Fix. Split the declarations across several builders over the same type. 1,200 fields in four builders of 300 compile with room to spare. Each build() returns its own validator, so run the ones a given call site needs.
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
type Wide = { a: string; b: string; c: string };
// One builder per group of fields, each bound to the same type. A path a
// builder does not declare is not validated by it, so splitting the
// declarations across several builders is the ordinary arrangement rather
// than a workaround for anything.
export const identity = Builder()
.use(requiredPlugin)
.for<Wide>()
.v('a', b => b.string.required())
.build();
export const contact = Builder()
.use(requiredPlugin)
.for<Wide>()
.v('b', b => b.string.required())
.v('c', b => b.string.required())
.build();required({ allowNull: true }) is rejected
Cause. .required() now narrows out null as well as undefined, and it takes no options beyond the uniform RuleOptions. Nullability is its own rule.
// Does not compile: required() takes no options beyond the uniform RuleOptions.
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
type Row = { closedAt: string | null };
export const rowValidator = Builder()
.use(requiredPlugin)
.for<Row>()
.v('closedAt', b => b.string.required({ allowNull: true }))
.build();import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { nullablePlugin } from '@maroonedog/luq/plugins/nullable';
type Row = { closedAt: string | null };
export const rowValidator = Builder()
.use(requiredPlugin)
.use(nullablePlugin)
.for<Row>()
.v('closedAt', b => b.string.required().nullable())
.build();"This expression is not callable" on the built validator
Cause. build() returns an object with four members, not a function. 1.x's README showed it being called; no implementation ever returned something callable.
// Does not compile: build() returns an object, not a function.
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
type User = { name: string };
const validateUser = Builder()
.use(requiredPlugin)
.for<User>()
.v('name', b => b.string.required())
.build();
export const broken = validateUser({ name: 'Jo' });Unexpected results
Only one issue comes back, but several fields are wrong
Cause. abortEarly and abortEarlyOnEachField both default to true. Pass { abortEarly: false } to collect every field's issues.
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 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' };
// ['name'] — the defaults: both abort flags are TRUE
export const one = formValidator.validate(input).issues.map(i => i.path);
// ['name', 'email'] — one issue per failing field
export const perField = formValidator
.validate(input, { abortEarly: false })
.issues.map(i => i.path);
// ['name', 'email', 'email'] — every failing rule
export const everything = formValidator
.validate(input, { abortEarly: false, abortEarlyOnEachField: false })
.issues.map(i => i.path);Inside an array element every field runs to completion whatever you passed, so one bad element never hides its siblings.
A missing field passes validation
Cause. There is no implicit required: a field that declares no presence rule accepts a missing value — presence. Add .required().
import { Builder } from '@maroonedog/luq';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';
type Draft = { title: string };
const draftValidator = Builder()
.use(stringMinPlugin)
.for<Draft>()
// No presence rule, so a MISSING title passes in silence.
.v('title', b => b.string.min(3))
.build();
export const missingIsAccepted = draftValidator.validate({}).valid; // trueA transform did not run
Cause. Transforms run only in parse(), and always last, whatever the chain order — the one order.
A gate ran too late, or a conditional produced the wrong issue
validateIf/skip. Every gate on a field is asked before any check runs — rule order.requiredIf/optionalIf. Presence rules, not checks: one issue, and the rest of the chain does not run — presence.
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { validateIfPlugin } from '@maroonedog/luq/plugins/validateIf';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';
type Ticket = { escalated: boolean; note: string };
const ticketValidator = Builder()
.use(requiredPlugin)
.use(validateIfPlugin)
.use(stringMinPlugin)
.for<Ticket>()
// Every gate on a field is asked BEFORE any check runs, so these two
// spellings are the same declaration.
.v('note', b => b.string.required().min(20).validateIf(root => root.escalated))
.build();
export const skipped = ticketValidator.validate({ escalated: false, note: 'x' }).valid;strict() did not reject an extra property
Cause. strict() is a compile-time obligation that every leaf path of your type is declared. It has no runtime effect at all. Rejecting unknown properties is a rule: use objectAdditionalProperties, documented on the Builder page.
Messages and codes
The error code I match on has changed
Cause. The default code is the plugin's name, with options.code overriding it.
| Rule | 1.x code | Now |
|---|---|---|
.required() | REQUIRED | required |
.min(3) on a string | VALIDATION_ERROR family | stringMin |
.custom(fn) | VALIDATION_ERROR | custom |
.orFail(fn) | VALIDATION_ERROR | orFail |
{ message: "…" } is not accepted on a rule
Cause. There is one message channel: messageFactory. RuleOptions is { code?, messageFactory?, severity? } and nothing else, and it is always the last argument of a chain method, after the plugin's own.
// Does not compile: RuleOptions has no `message` key.
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
type User = { email: string };
export const userValidator = Builder()
.use(requiredPlugin)
.for<User>()
.v('email', b => b.string.required({ message: 'Email is required' }))
.build();import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';
type User = { email: string; password: string };
export const userValidator = Builder()
.use(requiredPlugin)
.use(stringMinPlugin)
.for<User>()
// A constant message is a factory that ignores its context.
.v('email', b => b.string.required({ messageFactory: () => 'Email is required.' }))
// The context carries path, value and code; a plugin may add more.
.v('password', b =>
b.string.required().min(8, {
code: 'passwordTooShort',
severity: 'error',
messageFactory: context => context.path + ' needs at least 8 characters.',
})
)
.build();A call-level messageFactory and translate option existed in 1.x's ValidationOptions. Both are gone; pass the factory per rule.
Async checks
Checking a value against a database or an API
The whole @maroonedog/luq/async subpath is one await followed by the ordinary synchronous engine. It contains no rule, no plan and no loop over fields.
The external validate option is the pre-resolved bag that @maroonedog/luq/plugins/fromContext reads; @maroonedog/luq/async resolves the promises and passes them in on it.
import { Builder } from '@maroonedog/luq';
import { addAsyncSupport, createAsyncContext } from '@maroonedog/luq/async';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { stringEmailPlugin } from '@maroonedog/luq/plugins/stringEmail';
import { fromContextPlugin } from '@maroonedog/luq/plugins/fromContext';
import type { ExternalContext } from '@maroonedog/luq/plugins/fromContext';
type Signup = { email: string };
declare function isEmailTaken(email: string): Promise<boolean>;
// The context arrives untyped, so prove its shape with a guard.
function hasEmailTaken(
context: ExternalContext
): context is { readonly emailTaken: boolean } {
return typeof context.emailTaken === 'boolean';
}
// 1. An ordinary SYNCHRONOUS validator, decorated.
const signupValidator = addAsyncSupport(
Builder()
.use(requiredPlugin)
.use(stringEmailPlugin)
.use(fromContextPlugin)
.for<Signup>()
.v('email', b =>
b.string
.required()
.email()
.fromContext({
required: true,
check: (_value, context) => ({
valid: hasEmailTaken(context) && !context.emailTaken,
message: 'That address is already registered.',
}),
})
)
.build()
);
export async function checkSignup(input: Signup) {
// 2. Resolve everything outside, in parallel. This is the ONE await.
const context = await createAsyncContext()
.withOptions({ timeout: 2000 })
.set('emailTaken', isEmailTaken(input.email))
.build();
// 3. Validation itself is still synchronous under the promise.
return signupValidator.withAsyncContext(context).validate(input);
}Three names, and what each one is
createAsyncContext()— an immutable builder;.set(key, promise)returns a new one, and.build()resolves everything withPromise.allSettled, so entries run in parallel.addAsyncSupport(validator)— addswithAsyncContextwithout putting a fifth method on the core validator.readAsyncContext(ruleContext, isContext)— the read side, for plugin authors. It takes the guard that proves the shape, so a context that never resolved cannot masquerade as one that did. 1.x called itgetAsyncContext(context)and asserted instead.
One async lookup failed or timed out
import { createAsyncContext } from '@maroonedog/luq/async';
declare function loadQuota(): Promise<number>;
export async function buildContext() {
const context = await createAsyncContext()
.withOptions({
timeout: 2000,
continueOnError: true, // a failed entry still yields a ready context
defaultValues: { quota: 0 }, // ...substituting this
})
.set('quota', loadQuota())
.build();
if (context.hasErrors) {
for (const failure of context.failures) {
console.warn(failure.key, failure.reason);
}
}
// When isReady is false, validate() rejects with one issue per failed entry,
// code 'asyncContextUnavailable'.
return context.isReady;
}With continueOnError left off, a failed entry makes isReady false and the bound validator rejects immediately with one issue per failure, path = the entry's key and code asyncContextUnavailable. No field rule runs.
Bundle size
More Luq in the bundle than expected
First, check what "expected" was. Every entry is measured and re-measured by CI on every build: the bundle size table. The floor is 8,799 B for Builder with no plugins.
Adding a plugin costs 129–224 B of gzip, so if your bundle is much larger than the core plus your plugin count, something is pulling in more than you named.
// Per-plugin subpaths: the supported route.
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';
import { numberMinPlugin } from '@maroonedog/luq/plugins/numberMin';
// The barrel resolves too, and measured against the three lines above it costs
// 7,986 B gzipped versus 7,987 B — a 0.01% difference.
export const used = [requiredPlugin, stringMinPlugin, numberMinPlugin];- The barrel is not the problem when your bundler tree-shakes — it was measured at a 1 B difference over three plugins, which gzip to 9,261 B through the subpaths, and
check:barrel-equivalencefails the build if the two routes diverge by more than 5%. Check that you are emitting ES modules and that tree-shaking is on. jsonSchemaFullFeaturebundles the plugins a whole document needs, which is why one import covers it. ThejsonSchemaroute is billed as the tree-shakeable half and measured it is not — use it for the chain method, not to save bytes. Both are in the bundle size table.import * as plugins from '…'defeats tree-shaking in most bundlers. Name what you import.
Performance
Validation is slower than expected
First check: are you building inside the hot path? Building once at module scope is the single change that matters — what build() costs per shape.
Second: this release really is slower than 1.x on flat and nested shapes. Measured side by side in one process on one machine: ×0.21 on a single field, ×0.79 on three fields, ×0.83 nested. It is faster on arrays (×4.3) and JSON Schema documents (×2.1). 1.x carried a directory of specialised fast paths that this implementation has no equivalent of. If your workload is millions of flat-object validations per second, measure before upgrading.
Full measurementsGetting help
- 1. Check the 1.x mapping. If you are coming from 1.x, most surprises are on it: the result, the builder and the plugin registry each carry their own table.
- 2. Reduce it to one file. Start from the nearest example and add your case.
- 3. Search the issues at github.com/maroonedog/luq/issues, then open one with that file and the exact compiler or runtime output.