Skip to content

Field Builders

A field builder is an immutable chain that compiles to one field definition. The chain’s type carries the state of the declaration so far, which is what lets the compiler reject an option that cannot apply to the field you are declaring.

Every builder works the same way. A function from plumix/fields takes the field key and returns a builder. Each chained call returns a fresh instance carrying the new option. Registration calls build() on whatever it finds in a fields array, and what comes out is the plain definition the manifest ships and the server enforces.

import { number } from "plumix/fields";
export const servings = number("servings").min(1).max(50).required();

Chaining never mutates. number("servings").min(1) and the builder it was called on are two different objects, so passing a partial chain around cannot leak an option into an unrelated field.

A fields array accepts a builder or a compiled definition, since object-literal fields still register. Only the builder carries the phantom type parameters behind typed reads. An object literal declares a field the server enforces exactly as it enforces any other, and that field then drops out of the typed meta a theme reads.

The label is the one option you can usually skip. Leave .label() off and the builder derives one from the key. It splits on camel-case boundaries and on _, : and -, lower-cases the lot, then capitalizes the first character, so prepMinutes becomes “Prep minutes”, heroImage becomes “Hero image” and site_title becomes “Site title”. It is sentence case, not title case, so a name that has to keep its capitals needs .label().

  1. Write one chain. Order does not matter for most options, so read it as a sentence:

    import { text } from "plumix/fields";
    export const unit = text("unit")
    .label("Unit")
    .description("g, ml, tbsp")
    .maxLength(12)
    .required();
  2. Share the options two fields agree on. The key is fixed by the constructor, so wrap the chain rather than forking one instance:

    import { number } from "plumix/fields";
    const minutes = <K extends string>(key: K) =>
    number(key).min(0).max(600).step(5);
    export const prepMinutes = minutes("prepMinutes").label("Prep (minutes)");
    export const cookMinutes = minutes("cookMinutes").label("Cook (minutes)");

    The wrapper keeps the key literal, which is what later types the read as meta.prepMinutes.

  3. Watch an invalid chain fail. Add a string option to a numeric field and the compiler stops you before the site boots:

    import { number } from "plumix/fields";
    number("servings").maxLength(3);
    // Property 'maxLength' does not exist on type 'NumberFieldBuilder<"servings">'.

Eleven methods appear on nearly every builder, and the type of the argument changes with the field.

.label() and .description() set what the admin prints above and below the input. Both take a string or a Label descriptor for translation.

.default() stands in wherever the stored key is absent. The decoder fills it on every read, so a key nobody has saved still reads back the declared value — which is what lets the read type drop undefined. Storage is untouched: the default reaches the column the first time someone saves the form it prefilled, and until then storedMeta has no key at all.

.required() marks the field required. The write pipeline enforces it in strict mode only, so a save landing on a draft status does not fail on it and the publish does.

.span() sets the field’s width in the meta box’s 12-column grid. .capability() gates the field on a capability string, which the server enforces on write. .showInApi() opts the value into public REST responses, which are default-deny per field. .searchable() does the same for the site’s full-text index. It is honored on the text-shaped inputs — text, textarea, email, url and richtext — and ignored elsewhere, including on a password field and on a repeater row or group member, whose nested values are not walked.

.sanitize() normalizes a value after coercion and before persistence, and its return value replaces the caller’s input. .validate() runs last and returns true or a failure message, sync or async. Both are typed against the field’s own value type, so a number field’s validator receives a number.

.visibleWhen() and .orVisibleWhen() decide whether the admin shows the field. Rules come from sibling builders rather than from string keys, so a renamed field takes its conditions with it:

import { number, select } from "plumix/fields";
const difficulty = select("difficulty").options(["easy", "medium", "hard"]);
export const fields = [
difficulty,
number("restMinutes").visibleWhen(difficulty.is("hard")),
];

Each builder exposes the rule factories that suit its values. .isEmpty() and .isNotEmpty() are on every one. .is() and .isNot() are on every one except repeater and group, which hold a row list and a member object rather than a scalar to compare against. number and range add .gt() and .lt(), toggle adds .isOn() and .isOff(), a multi-value select adds .contains(), .notContains(), .countGt() and .countLt(), and a reference field after .multiple() adds .contains() and .notContains(). Registration rejects a rule naming a key no field in the box declares.

A repeater subfield or a group member may carry a condition too, judged against its own row or group rather than the box. A row’s rules therefore read that row’s siblings, and one row never speaks for another — which is what lets a row’s kind decide which of its own fields apply. Registration holds those rules to the same scope: a rule inside a row or group that names a box-level key is rejected, because it could never pass. A hidden cell is inactive on save as well as in the admin: it runs under the same rules a draft does, so business constraints cannot fail on an input nobody can open, while coercion, .sanitize() and the safety gates still run and the value itself is kept. Keeping it matters more inside a row than at box level, because a row is rewritten whole on every save — a cell dropped once would be gone for good.

Four mechanisms do the work, and none of them is a runtime check.

The method is not there. Each field type has its own builder class carrying only the options its renderer and its constraints understand. maxLength is on the string chain, min and max on the numeric and temporal ones, marks on richtext alone. Reaching for the wrong one is an ordinary missing-property error.

A seed blocks the chain until you supply what the type cannot work without. select, repeater, group and range return a seed object with exactly one method on it. Until you call it, there is no build(), and registration takes only things that have one.

import { range, select } from "plumix/fields";
select("difficulty").required();
// Property 'required' does not exist on type 'SelectFieldSeed<"difficulty">'.
range("spiceLevel").step(1);
// Property 'step' does not exist on type 'RangeFieldSeed<"spiceLevel">'.

select needs .options(), range needs .bounds(), and repeater and group need .fields(). A choice field with no choices and a slider with no track cannot be registered, and neither costs a runtime guard.

A this type gates a method on the chain’s state. Some methods only make sense once an earlier call has run, or only for one reference kind.

import { entry, select, user } from "plumix/fields";
select("garnishes").options(["basil", "mint"]).max(2);
// The 'this' context of type 'SelectFieldBuilder<..., false, ...>' is not
// assignable to method's 'this' of type 'SelectFieldBuilder<..., true, ...>'.
entry("pairsWith", ["recipe"]).roles(["editor"]);
// '.roles()' is a user-reference method.
user("testers").required().multiple();
// '.multiple()' after '.required()' would widen a shape already narrowed.

.max() wants an array to cap, so it waits for .multiple(). .roles() and .includeDisabled() belong to user, .status() and .includeTrashed() to entry. .multiple() has to come before .required(), because required narrows the value and the stored shape, and flipping to an array afterwards would leave the declared type lying about what is stored. On a select, .default() closes the same door.

An inferred literal union closes the value set. .options() reads its argument as literals, so the option list types everything downstream of it.

import { select } from "plumix/fields";
select("difficulty").options(["easy", "medium", "hard"]).default("simple");
// Argument of type '"simple"' is not assignable to parameter of type
// '"easy" | "medium" | "hard"'.

The same inference gates .appearance(). A single-value field takes select, radio or buttons, and a multi-value field takes buttons or checkboxes. The chain tracks both halves, so .appearance("radio").multiple() and .multiple().appearance("radio") both fail.

A register* call happens at runtime and cannot augment a type, so typed reads cost one declaration per box. Write the fields as a named const, declare the contribution against it, and register the same const.

import type { EntryMeta, ResolvedEntry } from "plumix";
import { number, select } from "plumix/fields";
import { definePlugin } from "plumix/plugin";
const recipeFields = [
number("servings").min(1).required(),
select("difficulty").options(["easy", "medium", "hard"]).default("medium"),
number("prepMinutes").label("Prep (minutes)").min(0),
];
declare module "plumix" {
interface EntryTypeRegistry {
recipe: { entry: ResolvedEntry };
}
interface EntryMetaContributions {
"recipe-details": EntryMeta<"recipe", typeof recipeFields>;
}
}
export const recipes = definePlugin("recipes", {
setup: (ctx) => {
ctx.registerEntryMetaBox("recipe-details", {
label: "Recipe details",
entryTypes: ["recipe"],
fields: recipeFields,
});
},
});

The key in EntryMetaContributions is the box id, and EntryMeta takes the entry type and the fields. The entry-type name is checked against EntryTypeRegistry, so a typo errors at the declaration rather than at a read site far away.

MetaOf<"recipe"> then folds every contribution targeting recipe into one record:

import type { MetaOf } from "plumix";
export function summarise(meta: MetaOf<"recipe">): string {
return `Serves ${String(meta.servings)}, ${meta.difficulty}`;
}

meta.servings is number because the field is required. meta.difficulty is "easy" | "medium" | "hard" because a default removes the undefined from the read type, which is a type-level narrowing rather than a runtime guarantee. meta.prepMinutes is number | undefined. The record is closed, with no index signature, so meta.serving is an error rather than unknown.

The declaration and the registration check each other. registerEntryMetaBox intersects a drift type onto its options, so an entryTypes list or a fields array that disagrees with the declaration fails the call with a message naming the mismatch. A box with no declaration registers as normal and reads as absent, and any package downstream can supply the missing declaration by merging the interface.

TermMeta, UserMeta and SettingsMeta follow the same pattern for the other three surfaces, folding through TermMetaOf, UserMetaOf and SettingsOf. Users have a flat keyspace, so a UserMeta contribution takes fields and no target.

Registration does three things to a fields array before the admin sees it. It compiles the builders down to definitions, it checks them, and it projects each definition to the wire shape the renderer reads. The first and the third are published, so a plugin that renders fields on a surface core does not — a front-end form, a panel of its own — runs the same pair instead of reimplementing it.

import { compileMetaBoxFields, text, toMetaBoxFieldEntry } from "plumix/fields";
const fields = [text("name").required(), text("email")];
const wire = compileMetaBoxFields(fields).map(toMetaBoxFieldEntry);

compileMetaBoxFields takes builders, plain definitions, or a mix of the two, and returns definitions. toMetaBoxFieldEntry takes one definition and returns the entry the admin receives. Repeater rows and group members recurse into subFields, and the callback options drop out, because .sanitize() and .validate() run on the server and nothing serialisable stands in for them.

The middle step is the one you do not get. registerEntryMetaBox and its siblings reject a key the write path would refuse, a key under the reserved __plumix_ prefix, a duplicate key within the box, more than 200 fields, and a .visibleWhen() rule naming a field the box does not declare. Project an array yourself and none of that runs. The last one is the one to watch, because the projection carries visibleWhen to the wire either way, and a renderer will evaluate a rule against a driver that was never declared. A repeater or a group is the exception: it checks its own children as .fields() is called, so a row’s rules are held to their scope however the array reaches the wire.

The builders here declare fields for boxes registered in Meta Boxes, and Field Types lists what each builder function produces. Overview covers what the compiled definition does at write time and read time. A theme consumes MetaOf through its template data, described in Template Data, and the entry type names it is keyed by come from Entry Types.

Go back to Field Types for the chain methods particular to each type, or on to Themes to read the values back out in a template.

Two pages ahead of this one are not written yet. Conditional Fields goes deeper into .visibleWhen(), including how a hidden field behaves when the entry is saved. Settings covers settings groups, which take the same builders and store their values per group instead of on an entity.