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.
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:
| Member | What 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.
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.
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;| Option | Type | Notes |
|---|---|---|
name | string | Defaults to 'value'. Documentation only; it is not the error code. |
description | string | Carried 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.
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
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.
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
requiredfrom its neighbours. - A rule cannot name a sibling field. Its root type is
unknown, soFieldPath<unknown>isneverand acompareFieldinside a reusable rule cannot compile against one root and be spliced into another.
Field rule or pick()?
| Use | When |
|---|---|
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. |
createFieldRule | The 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.x | Now |
|---|---|
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) |
ExtractFieldRuleType | ExtractFieldRuleValue |
rule._getInternalValidators() | Gone. rule.define is the callback, typed. |