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.