npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@reforgium/regula

v0.3.3

Published

Headless behavior and policy layer for Angular Signal Forms

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:

  1. regula supports the documented public Angular 22 Signal Forms contract, not private framework internals.
  2. regula must 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 Regula instance
  • 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:

  1. A real form can adopt it without rewriting form structure.
  2. The public API stays smaller than the app code it replaces.
  3. Cross-field behavior becomes more explicit, not more magical.
  4. Diagnostics can answer at least basic "why" questions.
  5. 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, or new Regula(form, { rules }) when the default Record<string, unknown> patch/payload types are enough.
  • regula.fields.key.snapshot()
  • regula.fields.key.normalized() for read-only preview
  • regula.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 shaping
  • regula.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:

  • Regula main class with new Regula(form, { rules })
  • createRegula<PatchSource, Payload>()(form, { rules }) for typed consumer setup without full class generics
  • form-wide defaults for 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(), and form.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 PrimeNG p-select and p-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.key
  • field(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().diagnostics
  • form.normalized().diagnosticsSummary
  • form.payload().diagnostics
  • form.payload().diagnosticsSummary
  • form.patch(source).diagnostics
  • form.patch(source).diagnosticsSummary

Useful field-rule additions:

  • defaults.errorVisibility for form-wide baseline error visibility
  • field.commitNormalized() for the common "normalize and write back" commit flow
  • field.commitNormalized({ touched: false }) for silent write-back without marking host touched state
  • form.submitAttempt() for submit-attempt state and optional payload result
  • submit.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 point
  • new 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 flow
  • regula.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 controlValue into the CVA, falling back to value
  • writes CVA changes back to both value and controlValue
  • calls markAsDirty() on CVA changes when the host field exposes it
  • calls markAsTouched() from the CVA touched callback when available
  • syncs host disabled() into setDisabledState(...) 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.codec
  • submit.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" }

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().value is inferred from the form field value type
  • rules keys are checked against known form keys
  • fields config keys are checked against known form keys
  • ctx.form.fields.product.value is 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 accessor

If 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;
// string

Typed 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 to getFormSnapshot(), but it must return undefined for 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: false is 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 Regula runtime
  • 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 Regula a layer above the host form system

Go / No-Go Questions

Proceed only if the answers remain "yes":

  1. Can this stay a behavior layer instead of a form platform?
  2. Can the first version solve concrete cases with a small API?
  3. Can dependent reset behavior stay app-level until a small enough shared helper proves itself?
  4. Can Angular integration stay behind a narrow host boundary?
  5. Will more than one package or app in the repo realistically reuse it?