Composition
Define a reusable field type once, as a record of projectors, and materialize it into an object of signals in each control. One inject, not one per attribute.
@mmstack/formsnpm
#Projectors
Most controls read the same handful of things off their field: the label, the first error, whether it is invalid, whether it is required. Writing those reads out by hand in every control is repetitive, and each one injects the field again. Composition lets you declare that set once and reuse it.
A projector is a pure function from the field handle to a value. compose injects the field once and turns a record of projectors into one object of signals. A fieldMetadata rule carries its own projector, and a bare MetadataKey composes directly.
import { REQUIRED } from '@angular/forms/signals';
import { compose, type FieldRef } from '@mmstack/forms';
const firstError = (f: FieldRef) => () => f.state().errors()[0]?.message ?? '';
readonly field = compose({
label: withLabel, // a fieldMetadata rule carries its projector
required: REQUIRED, // a MetadataKey composes directly
error: firstError,
invalid: (f: FieldRef) => () => f.state().invalid(),
});
// template: {{ field.label() }} / {{ field.error() }} Returns are normalized to signals: a signal is used as is, a getter is wrapped in computed, a plain value becomes a constant. Read field state lazily, returning a getter or signal, because compose runs in the control's initializers before the [formField] input is bound.
(f) => () => f.state().value(); // lazy, returns a getter
(f) => f.state().value; // eager, runs before the field is bound#Naming a field type
composition returns a [fragment, inject] tuple, mirroring fieldMetadata. The fragment is a plain record of projectors, so you extend one field type into another by spreading it.
import { composition, fromMetadata } from '@mmstack/forms';
const [textField, injectTextField] = composition({ label: withLabel, error: firstError });
const [select, injectSelect] = composition({
...textField, // extend by spreading
options: fromMetadata(OPTIONS, []),
});
// in a control:
readonly field = injectSelect(); // { label, error, options }, one inject#fromMetadata and raw
fromMetadata reads a metadata key, native (REQUIRED, MIN) or your own, as a composed signal. Without a fallback you can compose the bare key instead. With a fallback the projected signal never yields undefined.
import { MIN, REQUIRED } from '@angular/forms/signals';
import { composition, fromMetadata } from '@mmstack/forms';
const [numberField, injectNumberField] = composition({
required: fromMetadata(REQUIRED, false), // Signal<boolean>, never undefined
min: MIN, // bare key, Signal<number | undefined>
});injectField(projector) materializes a single projector, and raw(value) marks a projected value to skip signal-normalization, which is how methods like reset come through composition unchanged.
#Ready-made fragments
Some behavior is worth shipping as a fragment so field types adopt it by spreading, not by re-deriving. changeTracking() adds a changed signal and a reset method: reset() reverts the field to its baseline, and reset(initial) sets a new value and adopts it as the new baseline. reconciliation() is everything from changeTracking() plus a reconcile(incoming) method that merges server data while keeping in-flight edits.
import { changeTracking, reconciliation, composition } from '@mmstack/forms';
const [textField, injectTextField] = composition({
...changeTracking(), // adds changed + reset
label: withLabel,
});
const [entityField, injectEntityField] = composition({
...reconciliation(), // adds changed + reset + reconcile
label: withLabel,
});
// in a control:
const f = injectEntityField();
f.changed(); // Signal<boolean>
f.reset(); // revert to baseline
f.reconcile(server); // merge server data, keep in-flight edits Those methods are why raw() exists. A projector's return is normalized to a signal by default, which is right for changed but wrong for reset and reconcile: you want to call them, not read them through a signal. The fragments wrap each method in raw() so it passes through composition as the bare function. Reach for raw() yourself whenever a field type needs to expose a method or any other value that should not become a signal.
#The raw field handle
injectField and compose both build on injectFieldRef(), which resolves the FieldRef for the current [formField] host with a single injection. Most field types never call it, because a projector already receives the ref. Reach for it when you need the raw handle directly, for imperative work that does not fit the projector shape, for example calling a method on formField or reading state outside a composition. It throws when there is no bound field, and the name you pass shows up in that error.
import { injectFieldRef } from '@mmstack/forms';
// in a control's injection context:
const ref = injectFieldRef('myControl'); // FieldRef, throws if no [formField] host
ref.state(); // the live FieldState
ref.formField; // the bound FormField directive instance