@n0n3br/ngx-form-dependency-engine
Reactive dependency engine for Angular FormGroup: declare when → effect rules between controls —
"when these conditions hold against the form's value, apply these effects to a target control."
No visual builder, no schema framework. One engine class, pure functions, real Angular signals.
Features
- 15 condition operators (
equals,in,contains,matches,greaterThan,isEmpty, custom predicates…) AND/ORgroups with arbitrary nesting- Effects:
show·hide·enable·disable·setRequired·unsetRequired·addValidators/removeValidators(namespaced) ·setValue·setOptions - Per-dependency and per-key validator namespacing — strictly additive over baseline validators
- Chained rules propagate inside one pass; genuine cycles are capped (
maxIterations) and warned, never hang - Emit-free internal writes (
emitEvent: false) — no re-entrancy surprises hiddenFields/fieldOptionsexposed as real signals; zoneless-ready
Requires Angular ≥ 22 with Reactive Forms.
Install
npm install @n0n3br/ngx-form-dependency-engine
Quick start
import { Component, DestroyRef, inject } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import {
FormDependencyEngine,
dep,
f,
allOf,
show,
hide,
setRequired,
} from '@n0n3br/ngx-form-dependency-engine';
@Component({/* ... */})
export class QuoteComponent {
readonly form = new FormGroup({
kind: new FormControl<'PF' | 'PJ'>('PF'),
companyDoc: new FormControl(''),
personDoc: new FormControl(''),
income: new FormControl(0),
});
readonly engine = new FormDependencyEngine(this.form, [
dep('doc-type', 'companyDoc')
.when(f('kind').equals('PJ'))
.then(show(), setRequired())
.otherwise(hide())
.build(),
dep('premium', 'bonus')
.when(allOf(f('kind').equals('PF'), f('income').greaterThan(5000)))
.then(show())
.build(),
]);
constructor() {
inject(DestroyRef).onDestroy(() => this.engine.destroy());
this.engine.activate(); // runs an initial pass, then follows form.valueChanges
}
}
@if (!engine.isHidden('companyDoc')) {
<input [formControl]="form.controls.companyDoc" />
}
Directive usage
Prefer configuring rules straight from the template? Attach fdeDependencies to the
<form> element. The directive owns the engine lifecycle (build, activate, rebuild on
input change, destroy) and is exported as fde so templates read state directly.
import { Component } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import {
Dependency,
dep,
f,
hide,
setOptions,
setRequired,
show,
unsetRequired,
} from '@n0n3br/ngx-form-dependency-engine';
@Component({/* ... */})
export class SignupComponent {
readonly form = new FormGroup({
accountType: new FormControl('personal'),
companyName: new FormControl(''),
plan: new FormControl('Starter'),
});
readonly deps: Dependency[] = [
dep('business-company', 'companyName')
.when(f('accountType').equals('business'))
.then(show(), setRequired())
.otherwise(hide(), unsetRequired())
.build(),
dep('plan-options', 'plan')
.when(f('accountType').equals('business'))
.then(setOptions(['Team', 'Enterprise']))
.build(),
];
}
<form [formGroup]="form" [fdeDependencies]="deps" #fde="fde">
@if (!fde.isHidden('companyName')) {
<input formControlName="companyName" />
}
<select formControlName="plan">
@for (plan of fde.optionsFor('plan') ?? []; track plan) {
<option [value]="plan">{{ plan }}</option>
}
</select>
</form>
Directive API:
| Member | Description |
|---|---|
fdeDependencies |
Dependency[] input; pass null to run with no rules. Swapping the array at runtime rebuilds the engine. |
fdeConfig |
Optional FormDependencyEngineConfig (e.g. maxIterations). |
engine |
Readonly signal with the live FormDependencyEngine instance (or null before init). |
isHidden(path) |
Null-safe visibility read for templates. |
optionsFor(path) |
Null-safe option list read. |
reevaluate() |
Force an evaluation pass. |
Works with [formGroup] (reactive) and template-driven forms — it resolves whatever
ControlContainer hosts the element. Reactive forms are recommended since the engine
maps controls at construction time.
Core API
new FormDependencyEngine(form, dependencies, config?)
| Member | Description |
|---|---|
activate(): Subscription |
Runs the first evaluation pass, then re-evaluates on every form.valueChanges. |
destroy(): void |
Unsubscribes. |
reevaluate(): void |
Forces a manual evaluation pass. |
isHidden(field): boolean |
Reads the hiddenFields signal set. |
optionsFor(field): unknown[] | undefined |
Reads the fieldOptions signal map. |
hiddenFields: Signal<ReadonlySet<string>> |
Fields currently hidden by hide effects. |
fieldOptions: Signal<ReadonlyMap<string, unknown[]>> |
Option lists written by setOptions. |
settled: Signal<number> |
Bumps after every evaluation pass — use it in computed()s to track emit-free writes. |
config: { maxIterations?: number } — how many internal passes a single evaluation may take while still
changing state (default 5). Hitting the cap logs a circular-dependency warning.
Conditions
interface Condition {
field: string; // dot-path into the form value, e.g. "endereco.cidade"
operator: ConditionOperator;
value?: unknown;
custom?: (formValue: any) => boolean; // wins over operator entirely
}
Operators: equals · notEquals · in · notIn · contains · notContains ·
greaterThan · greaterOrEqual · lessThan · lessOrEqual · isEmpty · isNotEmpty ·
truthy · falsy · matches.
Groups combine with { logic: 'AND' | 'OR', conditions: [...] }; empty AND is vacuously true.
Effects
Every effect accepts an optional target override (defaults to the owning dependency's target).
| Effect | Extra payload | Behavior |
|---|---|---|
show / hide |
— | Toggles membership in hiddenFields. Never clears values. |
enable / disable |
— | Native control enable/disable; guarded no-op if already in that state. |
setRequired / unsetRequired |
— | Namespaced by the dependency id — two dependencies can require the same field independently. |
addValidators / removeValidators |
key, validators? |
Arbitrary validator sets under your own stable key. |
setValue |
value literal or (formValue) => value |
Writes with emitEvent: false. |
setOptions |
options literal or (formValue) => options |
Writes into the fieldOptions signal only. |
Validator management is strictly additive: validators present on a control before the engine ever touched it are preserved through every attach/detach cycle.
Evaluation loop
Because internal writes do not emit events, the engine loops internally: evaluate all dependencies → apply
effects → compare state by content (never by reference) → repeat until nothing changes or maxIterations
is reached. A chain A→B→C fully propagates within a single pass series; a true cycle caps out and warns.
Fluent rule builder
The builder layer produces plain, frozen contract objects — identical to hand-written literals.
import { allOf, anyOf, dep, f, not } from '@n0n3br/ngx-form-dependency-engine';
import { Validators } from '@angular/forms';
const dependency = dep('show-guarantee', 'guarantee')
.when(
allOf(f('amount').greaterThan(50000), anyOf(f('profile').equals('gold'), f('vip').truthy())),
)
.then(show(), setRequired(), addValidators('min-len', [Validators.minLength(3)]))
.otherwise(hide(), unsetRequired())
.build();
f(path)— every operator is a method:.equals(),.in([...]),.isEmpty(),.matches(/re/)….and()/.or()— chain groups:f('a').equals(1).and(f('b').equals(2)).or(f('c').equals(3))allOf(...)/anyOf(...)/not(condition)— combinators- Effect factories:
show(target?),hide(),enable(),disable(),setRequired(),unsetRequired(),addValidators(key, validators, target?),removeValidators(key, target?),setValue(valueOrFn, target?),setOptions(optionsOrFn, target?) dep(id, target).when(...).then(...).otherwise(...).build()— throws withoutwhen, freezes its output
Demo application
This repository ships an interactive showcase covering every feature:
git clone https://github.com/rogeriolaa/ngx-form-dependency-engine
cd ngx-form-dependency-engine
npm install
npm start # serves the demo at http://localhost:4200
Sections: Playground · Cascade · Validators Lab · Chains & Cycles · State Inspector · Builder Playground.
Development
| Command | Description |
|---|---|
npm run build:lib |
Build the publishable library to dist/. |
npm test |
Run the library unit tests (Vitest). |
npm run test:coverage |
Unit tests with a V8 coverage report (kept at ~99% statements). |
ng test demo |
Run demo tests. |
npm run lint |
ESLint across workspace. |
npm run build:demo |
Production build of the demo (Pages base href). |
License
MIT rogeriolaa