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.

typescriptDoes not compile
// 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];
typescript
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,
];
KeyWhat is behind it
@maroonedog/luqBuilder, the result vocabulary, the shared types. No plugins.
…/plugins/<name>One plugin each, camelCase. 77 of them.
…/pluginsThe convenience barrel.
…/resultunwrap, isOk, ok, reject, ValidationFailure.
…/field-rulecreatePluginRegistry, createFieldRule, useField.
…/asyncPromise resolution. Not async.experimental.
…/plugin-kitThe plugin-author surface.
…/package.jsonFor 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.

typescriptDoes not compile
// 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.

typescriptDoes not compile
// 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();
typescript
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".

typescriptDoes not compile
// 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.

typescriptDoes not compile
// 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();
typescript
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.

typescript
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.

typescriptDoes not compile
// 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();
typescript
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.

typescriptDoes not compile
// 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.

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 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().

typescript
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;   // true
A 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.
typescript
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.

Rule1.x codeNow
.required()REQUIREDrequired
.min(3) on a stringVALIDATION_ERROR familystringMin
.custom(fn)VALIDATION_ERRORcustom
.orFail(fn)VALIDATION_ERRORorFail
{ 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.

typescriptDoes not compile
// 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();
typescript
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.

signup.ts
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 with Promise.allSettled, so entries run in parallel.
  • addAsyncSupport(validator) — adds withAsyncContext without 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 it getAsyncContext(context) and asserted instead.
One async lookup failed or timed out
typescript
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.

typescript
// 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-equivalence fails the build if the two routes diverge by more than 5%. Check that you are emitting ES modules and that tree-shaking is on.
  • jsonSchemaFullFeature bundles the plugins a whole document needs, which is why one import covers it. The jsonSchema route 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 measurements

Getting help

  1. 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. 2. Reduce it to one file. Start from the nearest example and add your case.
  3. 3. Search the issues at github.com/maroonedog/luq/issues, then open one with that file and the exact compiler or runtime output.