API reference

Plugin registry & field rules

Three exports on one subpath: an immutable plugin bag, one field's rules lifted out of every object, and the free function that splices such a rule back into a builder.

Overview

A FieldRule is a v() callback that has been given a name and a plugin bag, and nothing else. It is deliberately not a validator of its own: everything it can do, it does by handing that callback to the ordinary builder. That is why a rule cannot produce anything a hand-written v() could not, and why it runs the same compiler, the same plan and the same runtime as every other field.

the whole idea
import { createPluginRegistry, useField } from '@maroonedog/luq/field-rule';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { stringEmailPlugin } from '@maroonedog/luq/plugins/stringEmail';

type Contact = {
  email: string;
  backupEmail: string;
};

// 1. A plugin bag that outlives any one builder.
const registry = createPluginRegistry()
  .use(requiredPlugin)
  .use(stringEmailPlugin);

// 2. One field's rules, named and reusable.
const emailRule = registry.createFieldRule<string>(
  b => b.string.required().email(),
  { name: 'email', description: 'A deliverable address' }
);

// 3. Spliced into a builder — twice, on two different paths.
const withEmail = useField(registry.toBuilder().for<Contact>(), 'email', emailRule);
export const contactValidator = useField(withEmail, 'backupEmail', emailRule).build();

// 4. And usable on its own, with no object around it.
export const standalone = emailRule.validate('ada@example.com').valid;

The ./field-rule subpath

1.x published these from the package root. They are on their own subpath now, so a project that never writes a reusable rule never pays for one.

import {
  createPluginRegistry,
  createFieldRule,
  useField,
} from '@maroonedog/luq/field-rule';

import type {
  PluginRegistry,
  FieldRule,
  FieldRuleDefine,
  FieldRuleOptions,
  FieldRuleSubject,
  ExtractFieldRuleValue,
} from '@maroonedog/luq/field-rule';

Import specifiers that do not resolve

@maroonedog/luq/core/registry, @maroonedog/luq/core and @maroonedog/luq/plugin are not export keys and never were. The ones that are.

createPluginRegistry()

Returns an empty registry. Its four members:

MemberWhat it does
use(plugin)Returns a new registry with the plugin added. First-wins by name.
createFieldRule<TValue>(define, options?)Mints a rule against this registry's plugins.
toBuilder()A fresh builder each call, carrying the same plugins.
getPlugins()The plugins, indexed by name, frozen.

use() is immutable here

This is the whole reason the registry still exists rather than being folded into the builder. A registry is meant to be a shared base that different teams branch, so its use() returns a new registry and leaves the receiver alone.

typescript
import { createPluginRegistry } from '@maroonedog/luq/field-rule';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { stringEmailPlugin } from '@maroonedog/luq/plugins/stringEmail';
import { numberMinPlugin } from '@maroonedog/luq/plugins/numberMin';

const base = createPluginRegistry().use(requiredPlugin);

// Each branch gets its OWN registry. `base` is never touched.
const emails = base.use(stringEmailPlugin);
const numbers = base.use(numberMinPlugin);

export const baseNames = Object.keys(base.getPlugins());      // ['required']
export const emailNames = Object.keys(emails.getPlugins());   // ['required', 'stringEmail']
export const numberNames = Object.keys(numbers.getPlugins()); // ['required', 'numberMin']

What it costs

Replaying happens when you ask for a builder or mint a rule, never while validating.

createFieldRule

Two doors to the same thing. The registry method is the ergonomic one because it takes a single type argument; the free function is there when you already have a builder. Called without type arguments the free form infers a loose value type, so reach for the registry form whenever the rule's value type matters.

typescript
import { Builder } from '@maroonedog/luq';
import { createFieldRule } from '@maroonedog/luq/field-rule';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';

// The builder comes FIRST. 1.x's shape was createFieldRule(define, options).
const slugRule = createFieldRule(
  Builder().use(requiredPlugin).use(stringMinPlugin),
  b => b.string.required().min(3),
  { name: 'slug' }
);

export const accepted = slugRule.validate('getting-started').valid;
OptionTypeNotes
namestringDefaults to 'value'. Documentation only; it is not the error code.
descriptionstringCarried on the rule for tooling to read.
fieldOptions{ default?, applyDefaultToNull? }Handed to v()'s third argument wherever the rule lands.

Running a rule on its own

A standalone rule is exactly Builder().use(...).for<{ value: TValue }>().v('value', define).build(), built once at createFieldRule() time. So issues come back under the path 'value': the rule is not attached to a place yet.

typescript
import { createPluginRegistry } from '@maroonedog/luq/field-rule';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { transformPlugin } from '@maroonedog/luq/plugins/transform';

const registry = createPluginRegistry().use(requiredPlugin).use(transformPlugin);

const trimmedRule = registry.createFieldRule<string>(
  b => b.string.required().transform(value => value.trim()),
  { name: 'trimmed' }
);

// validate() hands the value back untouched...
export const checked = trimmedRule.validate('  ada  ');

// ...parse() applies the transform, exactly as on a built validator.
export const parsed = trimmedRule.parse('  ada  ');

// Issues from a standalone rule are reported under the path 'value'.
export const paths = trimmedRule.validate(undefined).issues.map(issue => issue.path);

1.x had a third execution semantics here

1.x ran a different engine for field rules than for fields. There is one engine now, and one answer to "what does this rule mean".

Field options travel with the rule

typescript
import { createPluginRegistry, useField } from '@maroonedog/luq/field-rule';
import { optionalPlugin } from '@maroonedog/luq/plugins/optional';

type Settings = { language: string };

const registry = createPluginRegistry().use(optionalPlugin);

const languageRule = registry.createFieldRule<string>(
  b => b.string.optional(),
  { name: 'language', fieldOptions: { default: 'en' } }
);

// fieldOptions is carried to v()'s third argument, so the default applies
// standalone AND wherever the rule is spliced in.
export const settingsValidator = useField(
  registry.toBuilder().for<Settings>(),
  'language',
  languageRule
).build();

useField(builder, path, rule)

A free function, and the builder is its first argument. builder.useField(path, rule) is gone.

typescript
import { Builder } from '@maroonedog/luq';
import { createPluginRegistry, useField } from '@maroonedog/luq/field-rule';
import { requiredPlugin } from '@maroonedog/luq/plugins/required';
import { stringMinPlugin } from '@maroonedog/luq/plugins/stringMin';

type Article = {
  title: string;
  slug: string;
};

const registry = createPluginRegistry().use(requiredPlugin).use(stringMinPlugin);

const slugRule = registry.createFieldRule<string>(
  b => b.string.required().min(3),
  { name: 'slug' }
);

// A rule may be spliced into ANY builder that carries at least its plugins.
// This builder carries them, so the call typechecks.
const articleValidator = useField(
  Builder()
    .use(requiredPlugin)
    .use(stringMinPlugin)
    .for<Article>()
    .v('title', b => b.string.required().min(2)),
  'slug',
  slugRule
).build();

export const accepted = articleValidator.validate({
  title: 'Field rules',
  slug: 'field-rules',
}).valid;

The bag gate, and which implementation wins

  • The rule's bag is written as the builder's bag. A rule minted from plugins the builder does not carry is rejected at the call site; a builder carrying more than the rule needs is fine.
  • The chain is rebuilt from the builder's bag, not the registry's copy. If both register a plugin under one name, the builder's wins — one field may not run a different required from its neighbours.
  • A rule cannot name a sibling field. Its root type is unknown, so FieldPath<unknown> is never and a compareField inside a reusable rule cannot compile against one root and be spliced into another.

Field rule or pick()?

UseWhen
validator.pick(path)You already have a validator and want one of its fields — form field validation, a test on one path. It also passes siblings, so cross-field rules work.
createFieldRuleThe same rules belong on several paths, or in several validators, and you want one definition and one name for them.

Signatures

// Abridged: type parameter constraints omitted for readability.

function createPluginRegistry(): PluginRegistry<{}>;

interface PluginRegistry<B> {
  use<P>(plugin: P): PluginRegistry<B & BagEntry<P>>;       // immutable
  createFieldRule<TValue>(
    define: FieldRuleDefine<TValue, B>,
    options?: FieldRuleOptions<TValue>
  ): FieldRule<TValue, B>;
  toBuilder(): Builder<B>;
  getPlugins(): Readonly<Record<string, AnyPlugin>>;
}

function createFieldRule<TValue, B>(
  builder: Builder<B>,
  define: FieldRuleDefine<TValue, B>,
  options?: FieldRuleOptions<TValue>
): FieldRule<TValue, B>;

function useField<T, B, TDeclared, K>(
  builder: FieldBuilder<T, B, TDeclared>,
  path: K,
  rule: FieldRule<ValueAtPath<T, K>, B>
): FieldBuilder<T, B, TDeclared | K>;

interface FieldRule<TValue, B> {
  readonly name: string;
  readonly description: string | undefined;
  readonly define: FieldRuleDefine<TValue, B>;
  readonly fieldOptions: FieldOptions<TValue> | undefined;
  validate<TInput extends TValue | null | undefined>(
    value: TInput,
    options?: ValidateOptions
  ): ValidationResult<TInput>;
  parse(
    value: TValue | null | undefined,
    options?: ValidateOptions
  ): ValidationResult<TValue>;
}

Changed since 1.x

1.xNow
import { createPluginRegistry } from '@maroonedog/luq'from '@maroonedog/luq/field-rule'
registry.register(plugin)registry.use(plugin)
builder.useField(path, rule)useField(builder, path, rule)
createFieldRule(define, options)createFieldRule(builder, define, options)
ExtractFieldRuleTypeExtractFieldRuleValue
rule._getInternalValidators()Gone. rule.define is the callback, typed.