Writing a plugin
A plugin is a value. It has four members, it is created by one function, and it imports one subpath: @maroonedog/luq/plugin-kit. The 77 plugins that ship with Luq are written against exactly this surface — nothing about them is privileged.
The shape of a plugin
import { definePlugin, check, PASS, fail } from '@maroonedog/luq/plugin-kit';
import type { Unchanged } from '@maroonedog/luq/plugin-kit';
export const evenPlugin = /*#__PURE__*/ definePlugin<{
args: readonly []; // what the chain method takes
out: Unchanged; // what happens to the chain afterwards
context: { readonly actual: number }; // what messageFactory receives
}>()({
name: 'even', // the default error code
method: 'even', // what you call: b.number.even()
slots: ['number'] as const, // where the method appears
build: ctx =>
check({
code: ctx.code,
messageFactory: ctx.messageFactory,
severity: ctx.severity,
run: value =>
typeof value !== 'number' || value % 2 === 0
? PASS
: fail({ actual: value }),
describe: (_detail, messageContext) =>
`${messageContext.path} must be even`,
buildMessageContext: detail => ({
actual: typeof detail.actual === 'number' ? detail.actual : 0,
}),
}),
});The /*#__PURE__*/ annotation matters: it lets a bundler that ignores sideEffects: false still drop a plugin nobody imported.
Why definePlugin<Sig>()(spec) is two calls
The signature is written explicitly; the identity strings are inferred as literals from the spec. That split is what lets name, method and slots stay literal types while args, out and context stay yours to declare.
Using it
import { Builder } from '@maroonedog/luq';
type Basket = { count: number };
// Your plugin goes in the bag exactly like a built-in one. Nothing about the
// 77 shipped plugins is privileged; they are written against this surface.
const basketValidator = Builder()
.use(evenPlugin)
.for<Basket>()
.v('count', b => b.number.even())
.build();The signature is the contract
interface PluginSignature {
readonly args: readonly unknown[]; // what the chain method takes
readonly out: unknown; // what happens to the chain afterwards
readonly context: object; // what messageFactory receives
}args and out are written with markers — brands meaning "resolve this into the right thing at the call site, and into the right thing inside build()". A marker is never a value you construct; it only ever appears in a type position.
The 11 argument markers
| Marker | The call site writes | build() receives |
|---|---|---|
| FieldRef | FieldPath<TRoot> — a checked path literal | string |
| FieldRefs | readonly FieldPath<TRoot>[] | readonly string[] |
| RootPredicate | (root: TRoot, item?) => boolean | (root: unknown, item?) => boolean |
| RootReader<R> | (root: TRoot) => R | (root: unknown) => R |
| SelfReader<R> | (value: TValue) => R | (value: unknown) => R |
| SelfGuard | (value: TValue) => boolean | (value: unknown) => boolean |
| SelfValue | TValue — the field's own type | unknown |
| ElementChain | (b) => chain over the array's element | readonly Rule[] |
| NarrowedChain | (b) => chain over the narrowed value | readonly Rule[] |
| PropertyValueChain | (b) => chain over any property value | readonly Rule[] |
| PropertyKeyChain | (b) => chain over the property key (a string) | readonly Rule[] |
The caller writes typed things and build() receives erased things. A plugin cannot see the root type, so it cannot depend on it, so one plugin works for every type. Markers also nest — readonly ElementChain[] (a tuple's positions) and Readonly<Record<string, NarrowedChain>> (a key set, as patternProperties needs) both resolve, element by element and key by key.
The 4 output markers
out | build() must return | The chain becomes |
|---|---|---|
| Unchanged | a Rule | unchanged |
| TransformOut | a TransformRule | the map's return type |
| GuardOut | a Rule | same type, one union member covered |
| PresenceShift<K> | a PresenceRule | narrowed by K |
K is one of "excludeMissing" (what .required() declares), "excludeUndefined", "excludeNull" (.optional()) or "allowNull" (.nullable()).
out constrains what build() may return. The type the chain carries afterwards and the runtime policy the field gets are therefore the same declaration; they cannot drift apart.
This does not compile
import { definePlugin, check, PASS } from '@maroonedog/luq/plugin-kit';
import type { PresenceShift } from '@maroonedog/luq/plugin-kit';
export const broken = definePlugin<{
args: readonly [];
out: PresenceShift<'excludeMissing'>;
context: Record<string, never>;
}>()({
name: 'broken',
method: 'broken',
slots: ['string'] as const,
// `out` demands a PresenceRule; this is a CheckRule.
build: ctx =>
check({
code: ctx.code,
severity: ctx.severity,
run: () => PASS,
describe: () => '',
buildMessageContext: () => ({}),
}),
});TransformOut
TransformOut and GuardOut do not just constrain the return type — they replace the chain method's signature outright. TransformOut makes it <R>(map, options?), so the caller always supplies the mapping function; GuardOut makes it (condition, define, options?).
import { Builder } from '@maroonedog/luq';
import { definePlugin, transform } from '@maroonedog/luq/plugin-kit';
import type {
MessageContextExtra,
SelfReader,
TransformOut,
} from '@maroonedog/luq/plugin-kit';
// out: TransformOut => build() must return a TransformRule, and the chain
// method gets a FIXED signature the marker imposes:
// <R>(map: (value) => R, options?) => chain whose type is now R
const mapPlugin = /*#__PURE__*/ definePlugin<{
args: readonly [map: SelfReader<unknown>];
out: TransformOut;
context: MessageContextExtra;
}>()({
name: 'map',
method: 'map',
slots: ['string', 'number'] as const,
build: (_ctx, map) => transform(value => map(value)),
});
type Signup = { email: string };
const signupValidator = Builder()
.use(mapPlugin)
.for<Signup>()
.v('email', b => b.string.map(value => value.trim().length))
.build();
// Transforms run in parse() only, and only after every check on the field
// passed — never in validate().
signupValidator.parse({ email: ' a@b.com ' }); // data: { email: 7 }
signupValidator.validate({ email: ' a@b.com ' }); // data: { email: ' a@b.com ' }PresenceShift
import { definePlugin, presence } from '@maroonedog/luq/plugin-kit';
import type { MessageContextExtra, PresenceShift } from '@maroonedog/luq/plugin-kit';
// out: PresenceShift<'excludeMissing'> => build() must return a PresenceRule,
// and the chain's type loses undefined and null.
export const mandatoryPlugin = /*#__PURE__*/ definePlugin<{
args: readonly [];
out: PresenceShift<'excludeMissing'>;
context: MessageContextExtra;
}>()({
name: 'mandatory',
method: 'mandatory',
slots: ['string'] as const,
build: ctx =>
presence({
code: ctx.code,
messageFactory: ctx.messageFactory,
severity: ctx.severity,
allowUndefined: false,
allowNull: false,
emptyStringIsMissing: true,
describe: messageContext => `${messageContext.path} is mandatory`,
buildMessageContext: () => ({}),
}),
});The rule constructors
build() returns exactly one rule, made by one of eight constructors from @maroonedog/luq/plugin-kit.
| Constructor | Kind | Runs |
|---|---|---|
| check | a value test | after presence, in declaration order |
| presence | a presence policy | before every check |
| gate | should this field run at all | before every check; reports nothing |
| transform | rewrite the value | in parse() only, after all checks pass |
| branch / composite | label a rule list and combine sub-results | as one check |
| fieldsBranch | a rule list over named sibling fields | as one check |
| recursive | re-enter a plan on a nested structure | last |
check's run returns PASS or fail(detail). The detail is what describe() and buildMessageContext() read, and it is the reason a predicate runs once: the message travels with the outcome instead of being rebuilt by a second call.
Arguments, and rejecting bad ones
import {
definePlugin,
check,
PluginArgumentError,
PASS,
fail,
isString,
} from '@maroonedog/luq/plugin-kit';
import type { Unchanged } from '@maroonedog/luq/plugin-kit';
export interface PrefixContext {
readonly prefix: string;
readonly actual: string;
}
export const prefixPlugin = /*#__PURE__*/ definePlugin<{
args: readonly [prefix: string];
out: Unchanged;
context: PrefixContext;
}>()({
name: 'prefix',
method: 'prefix',
slots: ['string'] as const,
build: (ctx, prefix) => {
// A bad argument is a BUILD-time error, not a check that never fires.
if (prefix.length === 0) {
throw new PluginArgumentError(ctx.pluginName, 'prefix', prefix);
}
return check({
code: ctx.code,
messageFactory: ctx.messageFactory,
severity: ctx.severity,
// A wrong-typed value PASSES. The slot guard owns type and the presence
// rules own null/undefined; re-deciding either duplicates the issue
// under the wrong code.
run: value =>
!isString(value) || value.startsWith(prefix)
? PASS
: fail({ expected: prefix, actual: value }),
describe: (_detail, messageContext) =>
`${messageContext.path} must start with ${prefix}`,
buildMessageContext: detail => ({
prefix,
actual: isString(detail.actual) ? detail.actual : '',
}),
});
},
});Two conventions that are load-bearing
- A wrong-typed value passes. The slot guard owns type and the presence rules own
nullandundefined, so a value rule that re-decides either produces a duplicate issue under the wrong code. ctx.code,ctx.messageFactoryandctx.severityare already resolved from the caller'sRuleOptions, falling back to the plugin name and the builder's config. Read them; never re-resolve them. Two resolution sites is how an override stops working.
RuleBuildContext
build()'s first parameter carries everything a plugin may know.
| Member | Is |
|---|---|
| pluginName | the plugin's own name |
| code | options.code ?? pluginName |
| messageFactory | options.messageFactory, typed with your context |
| severity | options.severity ?? config.defaultSeverity |
| config | the ResolvedGlobalConfig in effect for this builder |
| fieldPath | the path this rule is attached to |
| declaredSiblingKeys | the immediate child keys declared under it |
There is no process-wide singleton read at validation time. A builder's .withConfig() reaches a plugin through ctx.config and nowhere else.
Sub-chains
Four of the argument markers hand your plugin a whole nested chain rather than a value. They differ only in what the sub-chain's subject is, and that is the thing to get right: none of the four can be derived from the others.
| Marker | The sub-chain's subject | Shipped example |
|---|---|---|
| ElementChain | one element of an array or tuple | arrayEach — b.array.each(e => …) |
| NarrowedChain | the value after a guard or condition narrowed it | unionGuard, conditionalSchema |
| PropertyValueChain | an object's property value | objectAdditionalPropertiesSchema |
| PropertyKeyChain | an object's property key, always a string | objectPropertyNames |
All four at their call sites:
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';
import { arrayEachPlugin } from '@maroonedog/luq/plugins/arrayEach';
import { objectPropertyNamesPlugin } from '@maroonedog/luq/plugins/objectPropertyNames';
import { objectAdditionalPropertiesSchemaPlugin } from '@maroonedog/luq/plugins/objectAdditionalProperties';
import { unionGuardPlugin } from '@maroonedog/luq/plugins/unionGuard';
type Inventory = {
skus: string[];
quantities: Record<string, number>;
code: string | number;
};
const inventoryValidator = Builder()
.use(requiredPlugin)
.use(stringMinPlugin)
.use(numberMinPlugin)
.use(arrayEachPlugin)
.use(objectPropertyNamesPlugin)
.use(objectAdditionalPropertiesSchemaPlugin)
.use(unionGuardPlugin)
.for<Inventory>()
// ElementChain — the sub-chain's subject is one ELEMENT of the array.
.v('skus', b => b.array.each(e => e.string.required().min(4)))
// PropertyKeyChain — the subject is the property KEY, always a string.
.v('quantities', b => b.object.propertyNames(k => k.string.min(2)))
// PropertyValueChain — the subject is any property VALUE.
.v('quantities', b => b.object.additionalPropertiesSchema(v => v.number.min(0)))
// NarrowedChain — the subject is the value NARROWED by the guard.
// `out: GuardOut` records the covered member in the chain's state, so
// `.build()` does not exist until every union member is covered.
.v('code', b =>
b.union
.guard(
(value): value is string => typeof value === 'string',
narrowed => narrowed.string.min(3)
)
.guard(
(value): value is number => typeof value === 'number',
narrowed => narrowed.number.min(1)
)
)
.build();A plugin must declare which argument positions carry a sub-chain.
import {
definePlugin,
branch,
composite,
PASS,
fail,
isArray,
} from '@maroonedog/luq/plugin-kit';
import type {
ElementChain,
MessageContextExtra,
Unchanged,
} from '@maroonedog/luq/plugin-kit';
export const eachPlugin = /*#__PURE__*/ definePlugin<{
args: readonly [element: ElementChain];
out: Unchanged;
context: MessageContextExtra;
}>()({
name: 'each',
method: 'each',
slots: ['array'] as const,
// WHICH positions carry a sub-chain cannot be recovered once the types are
// erased — a NarrowedChain and a RootPredicate are both plain functions —
// so the plugin declares it.
subChainArguments: [0],
build: (ctx, element) =>
composite({
code: ctx.code,
messageFactory: ctx.messageFactory,
severity: ctx.severity,
// The user's callback ran ONCE, at build() time. What arrives here is a
// compiled rule list.
branches: [branch('element', element)],
combine: runners => {
const runner = runners[0];
if (runner === undefined) return () => PASS;
return (value, runContext) => {
if (!isArray(value)) return PASS;
// This loop walks VALUES, not rules. A plugin never loops over
// rules itself: execution lives in exactly one place, the engine.
for (let index = 0; index < value.length; index += 1) {
const outcome = runner.run(value[index], runContext);
if (!outcome.ok) return fail({ ...outcome.detail, index });
}
return PASS;
};
},
describe: detail => `Element ${String(detail.index)} failed`,
buildMessageContext: () => ({}),
}),
});Never loop over rules
The loop in combine above walks values, not rules. That distinction is the rule: a plugin decides which member of the subject a compiled branch applies to, and the engine decides how a rule list executes. Execution exists in exactly one place. A plugin that iterates a rule list itself has forked the engine, and its rules will not honour abortEarly, severity, or issue paths the way every other rule does.
The same reasoning governs when your code runs. The callback the user writes is resolved once, at build() time. Nothing in a plugin should run per-validate() that could have run per-build().
Isolation and naming
Every plugin sits in one of two tiers, enforced by npm run check:plugin-isolation: an isolated plugin may import nothing but plugin-kit, types, path and its own directory.
That rule is why importing one plugin does not drag in the other 76, and it is measured: a plugin costs 129–224 B of gzip to add, against a 8,799 B builder. A plugin published from your own package has the same shape and imports @maroonedog/luq/plugin-kit instead of relative paths.
One method name per slot across the whole catalogue: npm run check:plugin-uniqueness refuses a collision.