Field metadata
Attach typed values to a field, like a label or a set of options, and read them back in the control. fieldMetadata bundles Angular's metadata system into one tuple that reads like a native rule.
@mmstack/formsnpm
#Defining a key
A control often needs data that is not part of the field's value: a label, a set of options, a hint. Signal Forms can carry it through their generic metadata system, but the read path is verbose and non-obvious. fieldMetadata bundles that system into one typed tuple that sets and reads like a native rule.
fieldMetadata<T>() returns a [rule, reader, key] tuple, mirroring injectable. Name the rule and reader at the call site.
import { fieldMetadata } from '@mmstack/forms';
export const [withLabel, injectLabel, LABEL] = fieldMetadata<string>({
debugName: 'label',
});#Setting and reading
Set the value in a schema next to required and min, then read it in a control the way you read an input(). The reader runs on a [formField] host and returns a signal.
import { form, required } from '@angular/forms/signals';
const f = form(model, (p) => {
required(p.name);
withLabel(p.name, 'Full name'); // set in the schema
});
// in the control, on a [formField] host:
readonly label = injectLabel('(unlabeled)'); // Signal<string> The value can be static or a reactive LogicFn. In the function form, the field's own value is typed, so a metadata value can depend on the field it sits on.
withLabel(p.count, ({ value }) => `${value().toFixed(0)} items`);
// value() is number-typed here#Resolution order
At read time the value resolves in this order: the value set in the schema, then the component fallback you pass to the reader, then the base fallback on the key, then undefined. Only undefined counts as unset, so a schema-set null is a real value and does not fall through. The reader's type reflects this: Signal<T> when a fallback is guaranteed, Signal<T | undefined> otherwise.
#The key
The third tuple element is the underlying MetadataKey, for stepping outside the sugar: set the attribute through the native metadata() rule, read it raw via FieldState.metadata(), or compose it directly.
import { metadata } from '@angular/forms/signals';
form(model, (p) => metadata(p.name, LABEL, () => 'Full name')); // native rule
state().metadata(LABEL)?.(); // raw read The key reads the raw accumulator, Signal<T | undefined>, with no fallbacks applied. Fallbacks are reader and projector sugar, so injectLabel() and state().metadata(LABEL)?.() can legitimately differ on a field that was never set.
One caveat: the reader must run on or under a [formField] host, because it resolves through the FORM_FIELD token that the directive provides. To read field state from a wrapper directive, inject that token. Do not declare your own formField input, or you trip the directive's pass-through and break the native value binding.