Core concepts

Six ideas carry the whole library: your type is the schema, a slot decides which methods exist, a plugin bag decides which of those you imported, a path is a checked string literal, presence is decided once before any check, and the result is a discriminated union.

Slots

Inside a .v() callback you first pick a slot — b.string, b.number, b.boolean, b.date, b.array, b.tuple, b.object, b.union, b.any. The slot is what decides which methods the chain offers: stringMin puts .min() on b.string and numberMin puts a different .min() on b.number, and the two never collide because their slots do not overlap.

Choosing a slot the field's type cannot be is a compile error.

typescriptDoes not compile
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { numberMinPlugin } from '@maroonedog/luq/plugins/numberMin';

type Draft = { title: string };

const draftValidator = Builder()
  .use(requiredPlugin)
  .use(numberMinPlugin)
  .for<Draft>()
  // `title` is a string. Choosing the number slot is a compile error.
  .v('title', b => b.number.required().min(3))
  .build();

A slot also has a run-time half. Entering b.<slot> seeds the chain with one rule — the value is of that type — so a field declared b.number and handed a string reports numberType, with no value rule on it at all.

SlotIssue codeAccepts
b.stringstringTypetypeof value === "string"
b.numbernumberTypetypeof value === "number" — NaN included
b.booleanbooleanTypetypeof value === "boolean"
b.datedateTypevalue instanceof Date
b.arrayarrayTypeArray.isArray(value)
b.objectobjectTypea plain object: not an array, not null

tuple, union and any seed nothing. any accepts everything by definition, and the other two are settled by the branches declared inside them rather than by one typeof.

Two things the seeded rule leaves alone

undefined and null pass it. Absence belongs to required, optional and nullable, which run in the same list — so a missing field reports required, not a type error, and .optional() can still switch it off.

NaN is a number. The rule answers the type question only; min, integer and finite are where you say what you think of NaN.

Plugins are the vocabulary

.use() puts a plugin in the builder's bag, and the bag decides which methods exist on which slots. There is no registry to populate and no global state; the bag is a type the builder carries.

typescript
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';
import { numberMinPlugin } from '@maroonedog/luq/plugins/numberMin';

type UserProfile = { name: string; age: number };

const validator = Builder()
  .use(requiredPlugin)    // adds .required() to every slot
  .use(stringMinPlugin)   // adds .min() to b.string
  .use(numberMinPlugin)   // adds .min() to b.number
  .for<UserProfile>()
  .v('name', b => b.string.required().min(2))
  .v('age', b => b.number.required().min(18))
  .build();

Because the bag is a type, an unimported plugin is not merely absent at run time — its method does not typecheck. That is the whole tree-shaking story: the bundle contains what you imported, and nothing you did not.

typescriptDoes not compile
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';

type Draft = { title: string };

const draftValidator = Builder()
  .use(requiredPlugin)
  .for<Draft>()
  // stringMinPlugin was never `use`d, so `.min` does not exist here.
  .v('title', b => b.string.required().min(3))
  .build();

Field paths

A path is a string literal TypeScript resolves against your type. Nested fields use dots; array elements use [*]. That is the entire grammar.

FormMeans
"name"a property of the root
"customer.name"a nested property
"items[*]"every element of items, as a whole value
"items[*].sku"sku of every element
"matrix[*][*]"every element of every sub-array
typescript
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';

type UserProfile = {
  name: string;
  address: { street: string; city: string };
  tags: string[];
};

const validator = Builder()
  .use(requiredPlugin)
  .use(stringMinPlugin)
  .for<UserProfile>()
  .v('name', b => b.string.required())
  .v('address.street', b => b.string.required())   // dot = nested property
  .v('address.city', b => b.string.required())
  .v('tags', b => b.array.required())              // the array itself
  .v('tags[*]', b => b.string.required().min(1))   // every element
  .build();

const rejected = validator.validate({
  name: 'Jo',
  address: { street: 'Main', city: 'Tokyo' },
  tags: ['ok', ''],
});

// -> ["tags[1]"] — the REAL index, never "tags[*]"
const failedPaths = rejected.issues.map(issue => issue.path);

The path you declare holds [*]; the path you read back holds the index the failure happened at. Every other form is a compile error, including reaching inside a Date or Map, which a path treats as opaque — the rejected forms.

Presence

Whether a value is absent is decided once, by the field's presence policy, before any check sees it. A check therefore never re-decides whether null is acceptable, and never re-decides the type either: the slot's seeded rule has already reported a wrong type under its own code, so stringMin answers pass for a non-string rather than failing it a second time. One invalid value produces one issue, not one per rule in the chain.

Declaredundefinednull""present
.required()rejectedrejectedrejectedchecks run
.optional()accepted, field endsrejectedchecks runchecks run
.nullable()rejectedaccepted, field endschecks runchecks run
nothingaccepted, field endsaccepted, field endschecks runchecks run

A field with no presence rule is not required

That is the last row, and it is one uniform answer replacing 1.x's split, where adding an array elsewhere in the schema switched implicit-required on for unrelated fields. .required() treating "" as absent is 1.x behaviour kept deliberately — 0, false, [] and {} all count as present.

typescript
import { Builder } from '@maroonedog/luq';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { optionalPlugin } from '@maroonedog/luq/plugins/optional';
import { nullablePlugin } from '@maroonedog/luq/plugins/nullable';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';

type Member = {
  id: string;
  nickname?: string;
  deletedAt: string | null;
  closedAt: string | null;
};

const memberValidator = Builder()
  .use(requiredPlugin)
  .use(optionalPlugin)
  .use(nullablePlugin)
  .use(stringMinPlugin)
  .for<Member>()
  .v('id', b => b.string.required().min(1))
  .v('nickname', b => b.string.optional().min(2))
  .v('deletedAt', b => b.string.nullable())
  // .required() narrows out null as well as undefined, so "present or
  // explicitly null, but never missing" is the two rules chained.
  .v('closedAt', b => b.string.required().nullable())
  .build();

requiredIf and optionalIf are presence rules too, not checks: a field that fails one produces exactly one issue, under the presence rule's own code, and no check on that field runs.

The order rules run in

For every field, always, whatever order you wrote the chain in:

default  ->  presence  ->  gates  ->  checks  ->  transforms  ->  recursion

build() sorts the chain into those buckets, so .min(3).validateIf(c) and .validateIf(c).min(3) mean the same thing. In 1.x the answer depended on where in the chain the call sat.

A gate is validateIf or its polarity-inverted twin skip: a predicate over the root value, asked before any check runs, with no message of its own to write when it closes. A closed gate silences the whole field rather than the rules after it, and reports nothing; a rejected presence rule ends the field with exactly one issue; and transforms run in parse() only, and only when every check passed.

That last bucket is the whole difference between the two entry points. validate() never changes your value; parse() applies the transforms, to a field whose checks all passed and only after they have. .transform() also changes the chain's type, so a transform to another type is reflected in what parse() hands back. Use validate() when you want to judge a value and keep it; use parse() when you want the value the rules produced.

Results

validate() and parse() return a discriminated union on valid. There is no errors member; narrowing on valid is what gives you data.

typescript
const result = validator.validate({ name: 'John', age: 25 });

if (result.valid) {
  // `data` is fully typed as UserProfile, and exists only on this branch.
  console.log(result.data.name);
} else {
  for (const issue of result.issues) {
    console.log(`${issue.path}: ${issue.message} [${issue.code}] ${issue.severity}`);
  }
}

The full shape, ValidationIssue, and why a non-error severity does not fail the result: the validator API.

Next steps