@reforgium/regula
v0.3.3
Published
Headless behavior and policy layer for Angular Signal Forms
Maintainers
Readme
@reforgium/regula
regula is a headless behavior layer above Angular Signal Forms.
It is not a new form engine, not a schema-driven renderer, and not a replacement for Angular forms APIs. The package should exist only if it helps move repeated form behavior out of app-level glue code into a small, composable, explainable set of policies.
Angular 22.0.0 is the minimum supported version. The package peer range is >=22.0.0 <23.0.0, and the
packed artifact is verified against a clean Angular 22 consumer in addition to the workspace test and build pipeline.
Angular 22 promotes Signal Forms to a stable public API. regula targets that stable API surface directly while
remaining a behavior layer above the host form rather than absorbing form structure or validation ownership.
Problem
Angular Signal Forms can model field state well, but application teams still repeatedly solve the same behavior problems in ad hoc ways:
- value normalization while typing, on commit, and on submit
- error visibility rules
- patching external/query/API data into existing form fields
- payload shaping for APIs
- server error mapping
- diagnostics for "why did this happen?"
Without a dedicated layer, this logic usually ends up split across effects, submit handlers, mappers, directives, and component-local utilities.
regula is aimed at deterministic form behavior. It does not try to own async orchestration such as lookups,
dictionary preloads, autocomplete requests, service writes, or store effects. Those flows should stay in application
integration code and can call Regula APIs at their boundaries.
Intended Position
Angular should own:
- form structure
- field lifecycle
- validation execution
- public form primitives
- the stable public Signal Forms API surface
regula should own:
- behavior policies around patch, normalization, submit attempts, and payloads
- model-to-payload transitions
- visible-error rules
- format boundaries such as string/date/null conversion
The host form should still own field state such as disabled, readonly, hidden, pending, valid, and errors.
Non-Goals
regula should not become:
- a replacement form engine
- a UI schema renderer
- a layout builder
- a giant config DSL
- a private Angular API wrapper
- a CVA-centric abstraction layer
If the library starts describing the entire form instead of describing how an existing form behaves, scope has drifted.
The RC surface should be judged against this rule especially carefully: RegulaFieldRules is useful only while it
replaces scattered behavior glue rather than becoming a second form schema.
Host Boundary
regula should treat Signal Forms as the default host, with the binding hidden behind the main Regula API.
That boundary exists for two reasons:
regulasupports the documented public Angular 22 Signal Forms contract, not private framework internals.regulamust remain a layer above the host form system, not a replacement for it.
The default scenario should be:
new Regula(form, { rules })- host-form access hidden behind the main
Regulainstance - built-in payload and state reads over that host
One Regula instance should own behavior for one host form.
If multiple forms need to collaborate, coordination belongs outside regula.
The lower-level adapter contract should remain available only as an advanced option for non-Signal-Forms hosts.
Value Test
The library is worth building only if v0 can prove all of the following:
- A real form can adopt it without rewriting form structure.
- The public API stays smaller than the app code it replaces.
- Cross-field behavior becomes more explicit, not more magical.
- Diagnostics can answer at least basic "why" questions.
- The implementation depends only on stable public Angular 22 form APIs.
If those conditions are not met, the behavior should stay in app-level code instead of becoming a shared library.
Design Constraints
- Signal-first where possible.
- Deterministic execution and override order.
- Small composable policies over one orchestration object.
- Field behavior separate from rendering.
- Explicit separation between:
- input value, model value, payload value
- invalid state, visible error state
- host field state, regula submit/error behavior
Consumer API Contract
The supported root API is intentionally consumer-first.
Use these as the default path:
createRegula<PatchSource, Payload>()(form, { rules })for typed app setup, ornew Regula(form, { rules })when the defaultRecord<string, unknown>patch/payload types are enough.regula.fields.key.snapshot()regula.fields.key.normalized()for read-only previewregula.fields.key.commitNormalized()for normalize-and-write-back commit flows. It marks the field as touched by default; pass{ touched: false }only for silent host/store synchronization.regula.form.patch(partialSource)for direct inbound patching. Missing source values are reported as diagnostics and do not overwrite existing field values.regula.form.payload()/regula.form.payload<MyPayload>()for submit payload shapingregula.form.submitAttempt()for submit-attempt behavior and an{ ok, blocked, payload }result
State such as disabled, readonly, hidden, pending, valid, and errors is read from the host form.
Regula does not compute or mutate that state.
Payload behavior is explicit in submit.
Regula does not silently omit a field only because the host says it is hidden or disabled.
Use submit.include, submit.canSubmit, or submit.as to describe the payload contract.
Dependent resets, option reloading, async lookups, and derived application values are intentionally app-level for now. They can call Regula APIs at their boundaries, but they are not part of the core runtime contract.
Proposed Public Surface for v0
v0 should stay narrow and headless.
- normalization pipeline
- validation visibility strategy
- patching and payload serialization
- payload serialization
- minimal diagnostics reasons
- submit-attempt behavior
Everything else should justify its place later.
Implemented Surface
Current v0 already includes:
Regulamain class withnew Regula(form, { rules })createRegula<PatchSource, Payload>()(form, { rules })for typed consumer setup without full class generics- form-wide
defaultsfor baseline field behavior - field-centric rules
- built-in Signal Forms host integration
- field-level
submit.canSubmit(...)payload gating - typed top-level
fields.*access from the host form shape - diagnostics summaries on
form.normalized(),form.payload(), andform.patch() - typed payload serialization and partial inbound patching
- regula-oriented field codecs/presets
- submit-attempt result gating through
form.submitAttempt() [regulaCva]standalone CVA bridge for controls such as PrimeNGp-selectandp-password
Example
For app code, prefer createRegula<PatchSource, Payload>()(form, options).
It keeps the host form inferred, carries patch/payload types through the instance, and avoids full Regula<...> class
generics at the call site:
import { createRegula } from '@reforgium/regula';
type CheckoutPatchSource = {
profile: {
email: string;
};
delivery: {
country: string;
city: string;
};
};
type CheckoutPayload = {
email: string;
country?: string;
city?: string;
};
const regula = createRegula<CheckoutPatchSource, CheckoutPayload>()(checkoutForm, {
defaults: {
errorVisibility: 'dirty-or-submit',
},
submit: {
dirtyOnAttempt: true,
touchOnAttempt: false,
},
rules: {
email: {
normalize: {
commit: ['trim', 'lowercase'],
},
codec: 'trimmed-string',
patch: {
from: 'profile.email',
},
submit: {
include: true,
codec: 'trimmed-string',
},
},
country: {
patch: {
from: 'delivery.country',
},
submit: {
include: ({ field }) => !!field.value,
map: ({ field }) => String(field.value ?? '').toUpperCase(),
},
},
city: {
patch: {
from: 'delivery.city',
},
submit: {
canSubmit: ({ field }) => !field.disabled && !!field.value,
},
},
hasMiddleName: {
submit: {
include: true,
},
},
middleName: {
submit: {
include: ({ field }) => !field.hidden,
},
},
},
});
const emailCommit = regula.fields.email.commitNormalized();
const emailErrors = regula.fields.email.errorVisibility();
const emailRef = regula.fields.email;
regula.form.patch({
profile: { email: ' [email protected] ' },
delivery: { country: 'kg', city: 'Bishkek' },
});
const submit = regula.form.submitAttempt();
if (submit.ok) {
const payload = submit.payload;
}form.patch(...) accepts a typed partial patch source. Missing source paths are diagnostics only and do not overwrite
existing field values. If the payload type is easier to name at the call site than at construction time, use
form.payload<MyPayload>() instead of a cast.
Dependent field clearing and option reloading should stay in application code. For example, when country changes, the
app can clear city and reload city options through its own store/resource layer. Regula should only describe how the
current form value is patched, normalized, and serialized.
Available main APIs:
field(key)/fields.keyfield(key).snapshot()field(key).normalized()field(key).commitNormalized()field(key).errorVisibility()form.snapshot()form.normalized()form.payload()form.patch(source)form.connect()/form.disconnect()form.submitAttempt()
Diagnostics:
form.normalized().diagnosticsform.normalized().diagnosticsSummaryform.payload().diagnosticsform.payload().diagnosticsSummaryform.patch(source).diagnosticsform.patch(source).diagnosticsSummary
Useful field-rule additions:
defaults.errorVisibilityfor form-wide baseline error visibilityfield.commitNormalized()for the common "normalize and write back" commit flowfield.commitNormalized({ touched: false })for silent write-back without marking host touched stateform.submitAttempt()for submit-attempt state and optional payload resultsubmit.canSubmit(ctx)for payload-level submit gating
There is also a live sandbox example at test-routing/regula, showing:
new Regula(...)as the central integration pointnew Regula(form, { rules })as the default host scenario[regulaCva]as the CVA bridge for third-party controls that cannot use Signal Forms[formField]directly- email commit normalization
- form-wide default error visibility
- payload include/canSubmit behavior
- host disabled/hidden state read from Signal Forms
- error visibility after submit attempt
- final serialized payload and omission reasons, including payload-level submit blocking
The runtime specs also include realistic pressure examples:
regula.usage.spec.ts- checkout-like patch, normalization, submit attempt, and payload flowregula.real-form.spec.ts- conditional company billing branch with explicit payload omission
CVA Bridge
Use [regulaCva] when a control already implements Angular ControlValueAccessor, but cannot be bound with Signal
Forms [formField] directly. This is the recommended recipe for PrimeNG controls such as p-select and p-password.
import { RegulaCvaControl } from '@reforgium/regula';
import { Select } from 'primeng/select';
@Component({
standalone: true,
imports: [RegulaCvaControl, Select],
})
export class CheckoutComponent {}<p-select [options]="countries" optionLabel="name" optionValue="code" [regulaCva]="checkoutForm.country" />The bridge removes the repeated:
[ngModel]="field().value()"[ngModelOptions]="{ standalone: true }"(ngModelChange)="field().value.set($event)"[disabled]="field().disabled()"
Bridge behavior:
- writes the current Signal Forms
controlValueinto the CVA, falling back tovalue - writes CVA changes back to both
valueandcontrolValue - calls
markAsDirty()on CVA changes when the host field exposes it - calls
markAsTouched()from the CVA touched callback when available - syncs host
disabled()intosetDisabledState(...)by default
If a control owns disabled state itself, opt out with [regulaCvaSyncDisabled]="false".
[regulaCva] is intentionally not a form renderer. Physical disabled/hidden state still belongs to the Signal Forms
host field.
Codecs
regula supports narrow bidirectional field codecs for the common case where:
- server or storage payload comes in one shape
- component or form logic works with another shape
- submit payload needs to be shaped again on the way out
Field rules can use:
- top-level
codec patch.codecsubmit.codec
Built-in presets are exported as REGULA_CODEC_PRESETS, and the resolver also supports preset names directly.
Typical uses:
- string trimming
- lowercased transport values
- empty-string/null bridging
- number/string conversion
- date-only formatting
- month-only formatting
The low-level serializer engine is shared through hidden @reforgium/internal, but regula keeps a smaller form-oriented codec surface.
Compound Payload Mapping
Some UI fields represent one logical input but do not match backend payload shape.
Typical example:
- PrimeNG date range keeps one field such as
period: [Date | null, Date | null] - backend expects either:
- one scalar string such as
period: "2026-04-01..2026-04-09" - or a payload fragment such as
{ start: "2026-04-01", end: "2026-04-09" }
- one scalar string such as
For this case, submit.as can choose between the normal field value path and root payload fragment merge:
period: {
patch: ({ source }) => [source.start, source.end],
submit: {
include: true,
as: 'fragment',
map: ({ field }) => {
const [start, end] = field.value as [string | null, string | null];
return {
start,
end,
};
},
},
}Use as: 'value' for the normal payload[field] = value path.
Use as: 'fragment' when one form field should emit multiple payload keys.
For common range fields, regula also exports helper rules that keep the patch and submit.as: 'fragment'
boilerplate out of app components:
import { dateRangeFragmentField, monthRangeFragmentField, rangeFragmentField } from '@reforgium/regula';
const regula = createRegula<QuerySource, QueryPayload>()(filterForm, {
rules: {
period: dateRangeFragmentField({
from: 'start',
to: 'end',
}),
saldoPeriod: monthRangeFragmentField({
from: 'periodFrom',
to: 'periodTo',
}),
customRange: rangeFragmentField<string>({
from: 'left',
to: 'right',
include: 'any',
}),
},
});dateRangeFragmentField(...) patches yyyy-MM-dd values into [Date | null, Date | null] and submits the same
keys back as yyyy-MM-dd.
monthRangeFragmentField(...) does the same for yyyy-MM month periods.
Use patchFrom / patchTo when source keys differ from the logical range names, and submitFrom / submitTo when
payload keys should differ from patch keys.
For backends that still want one scalar payload value, keep the normal field key path:
period: {
submit: {
include: true,
as: 'value',
map: ({ field }) => {
const [start, end] = field.value as [string | null, string | null];
return `${start ?? ''}-${end ?? ''}`;
},
},
}Typing
For the standard new Regula(form, { rules }) path, regula now binds field APIs and rule keys to the host form shape.
That means:
regula.fields.email.normalized().valueis inferred from the form field value typeruleskeys are checked against known form keysfieldsconfig keys are checked against known form keysctx.form.fields.product.valueis typed inside short-constructor rule callbacks- nested Signal Forms fields use typed dotted paths; both the field value and original accessor are inferred
const regula = new Regula(taxpayerForm, {
rules: {
'legalPerson.tin': {},
},
});
const tinField = regula.fields['legalPerson.tin'];
// tinField.formField is typeof taxpayerForm.legalPerson.tin
// tinField.snapshot().value is inferred from that accessorIf submit.canSubmit(...) is present, you do not need to repeat include: true.
include already defaults to true when a submit block exists.
Scalar Fields
Typical scalar fields should not need manual type arguments:
const regula = new Regula(checkoutForm, {
rules: {
email: {
normalize: {
commit: ['trim', 'lowercase'],
},
},
},
});
const email = regula.fields.email.normalized().value;
// stringTyped Rules Map
If you want an explicit rules object before constructing Regula, use RegulaTypedFieldRulesMap:
import type { RegulaTypedFieldRulesMap } from '@reforgium/regula';
type CheckoutForm = typeof checkoutForm;
const rules: RegulaTypedFieldRulesMap<CheckoutForm> = {
email: {
errorVisibility: 'dirty-or-submit',
},
};Typed Factory And Options Helper
createRegula<PatchSource, Payload>()(form, options) is the recommended typed construction path for application code:
import { createRegula } from '@reforgium/regula';
const regula = createRegula<CheckoutPatchSource, CheckoutPayload>()(checkoutForm, {
rules: {
email: {
patch: {
from: 'profile.email',
map: ({ source }) => source.profile.email.trim(),
},
submit: {
include: true,
},
},
},
});
regula.form.patch({
profile: {
email: ' [email protected] ',
},
});
const payload = regula.form.payload().payload;If you want to keep typed options in a separate constant, use defineRegulaOptions(...):
import { defineRegulaOptions, Regula } from '@reforgium/regula';
const options = defineRegulaOptions<CheckoutPatchSource, CheckoutPayload>(checkoutForm, {
rules: {
email: {
patch: {
from: 'profile.email',
map: ({ source }) => source.profile.email.trim(),
},
},
},
});
const regula = new Regula(checkoutForm, options);Advanced Adapter API
The default consumer API is still new Regula(signalForm, { rules }).
Use the adapter config only when the host is not the built-in Signal Forms path, or when a test/runtime integration must provide its own form snapshot and patch writer:
import { Regula } from '@reforgium/regula';
import type { RegulaFieldAdapter } from '@reforgium/regula';
const getFormSnapshot: RegulaFieldAdapter['getFormSnapshot'] = () => ({
fields: {
email: {
path: 'email',
value: store.email,
dirty: store.emailDirty,
touched: store.emailTouched,
pending: false,
valid: store.emailValid,
invalid: !store.emailValid,
disabled: false,
},
},
submitted: store.submitted,
submitAttempted: store.submitAttempted,
pending: false,
valid: store.emailValid,
invalid: !store.emailValid,
});
const adapter: RegulaFieldAdapter = {
getFormSnapshot,
getFieldSnapshot: (path) => getFormSnapshot().fields[path],
applyPatches: (patches) => {
for (const patch of patches) {
store.patchField(patch.field, patch);
}
},
};
const regula = new Regula({
adapter,
autoConnect: false,
rules: {
normalization: {
email: [
{
kind: 'normalization',
name: 'trim',
phase: 'commit',
run: ({ field }) => ({
value: String(field.value).trim(),
inputValue: String(field.inputValue ?? field.value).trim(),
}),
},
],
},
},
});For a complete root-import example, see regula.usage.spec.ts.
Adapter ownership is deliberately small:
getFormSnapshot()must return the current host state; Regula does not cache host fields as source of truth.getFieldSnapshot(path)may delegate togetFormSnapshot(), but it must returnundefinedfor unknown fields.applyPatches(...)is the only write bridge. The host decides how patches update value, input value, touched state, disabled state, errors, or metadata.reportDiagnostics(...)is optional and should be used for app logs, devtools, or test assertions.autoConnect: falseis recommended unless the adapter is backed by Angular Signal Forms inside an injection context.
Do not use the adapter overload just to customize ordinary Signal Forms behavior. Keep Signal Forms as the host source of
truth and use field rules, codecs, form.patch(...), field.commitNormalized(...), and form.payload(...) from the
main API first.
Date Period Fields
Compound fields such as PrimeNG date ranges are still harder for TypeScript to infer perfectly, especially when the field value is a tuple or custom object.
For those cases, regula now helps with:
- key-safe field rules
- value inference for
regula.fields.period - field-level patch/payload support
But a local cast inside submit.map(...) can still be reasonable when the UI field shape is complex:
period: {
submit: {
as: 'fragment',
map: ({ field }) => {
const [start, end] = field.value as [Date | null, Date | null];
return {
start: start ? formatDate(start) : null,
end: end ? formatDate(end) : null,
};
},
},
}The goal is to keep as local to the compound field boundary instead of scattering casts across the whole form integration.
Not in v0
- broad async orchestration
- debounce policy framework
- state machine for sections or whole forms
- changed-only submission engine
- advanced server reconciliation model
- renderer integration layer
- schema-driven configuration
Package Status
regula is still early and intentionally narrow, but it is no longer only a package scaffold.
The release path toward a stable 1.0.0 contract is tracked in RELEASE-1.0.0.md.
What exists now:
- working Nx package
- implemented
Regularuntime - built-in Signal Forms host path
- sandbox page at
test-routing/regula - initial codec/patch/payload path
- submit-attempt behavior
What still needs pressure:
- real-form validation against more than one consumer
- whether dependent resets deserve a small app-facing helper later
- continued pressure to keep
Regulaa layer above the host form system
Go / No-Go Questions
Proceed only if the answers remain "yes":
- Can this stay a behavior layer instead of a form platform?
- Can the first version solve concrete cases with a small API?
- Can dependent reset behavior stay app-level until a small enough shared helper proves itself?
- Can Angular integration stay behind a narrow host boundary?
- Will more than one package or app in the repo realistically reuse it?
