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.

bash
npm install @maroonedog/luq

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

tsconfig.json
{
  "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.

user.ts
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.

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

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

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

typescript
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

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

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

typescript
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: () => "..." }.

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

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

typescript
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