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

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

typescript
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

typescript
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

MarkerThe call site writesbuild() receives
FieldRefFieldPath<TRoot> — a checked path literalstring
FieldRefsreadonly 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
SelfValueTValue — the field's own typeunknown
ElementChain(b) => chain over the array's elementreadonly Rule[]
NarrowedChain(b) => chain over the narrowed valuereadonly Rule[]
PropertyValueChain(b) => chain over any property valuereadonly 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

outbuild() must returnThe chain becomes
Unchangeda Ruleunchanged
TransformOuta TransformRulethe map's return type
GuardOuta Rulesame type, one union member covered
PresenceShift<K>a PresenceRulenarrowed 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

typescriptDoes 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?).

typescript
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

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

ConstructorKindRuns
checka value testafter presence, in declaration order
presencea presence policybefore every check
gateshould this field run at allbefore every check; reports nothing
transformrewrite the valuein parse() only, after all checks pass
branch / compositelabel a rule list and combine sub-resultsas one check
fieldsBrancha rule list over named sibling fieldsas one check
recursivere-enter a plan on a nested structurelast

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

prefix.ts
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 null and undefined, so a value rule that re-decides either produces a duplicate issue under the wrong code.
  • ctx.code, ctx.messageFactory and ctx.severity are already resolved from the caller's RuleOptions, 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.

MemberIs
pluginNamethe plugin's own name
codeoptions.code ?? pluginName
messageFactoryoptions.messageFactory, typed with your context
severityoptions.severity ?? config.defaultSeverity
configthe ResolvedGlobalConfig in effect for this builder
fieldPaththe path this rule is attached to
declaredSiblingKeysthe 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.

MarkerThe sub-chain's subjectShipped example
ElementChainone element of an array or tuplearrayEach — b.array.each(e => …)
NarrowedChainthe value after a guard or condition narrowed itunionGuard, conditionalSchema
PropertyValueChainan object's property valueobjectAdditionalPropertiesSchema
PropertyKeyChainan object's property key, always a stringobjectPropertyNames

All four at their call sites:

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';
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.

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

Next steps