Skip to main content

Migration guides

Moving to 5.0.0

Inferred field nullability

Breaking: field('') now produces FieldNode<string> instead of FieldNode<string | null>. The same inference applies to form/group shorthands and array templates, including dynamically added children. Validators, callbacks, aggregate values, and extracted FormNodeValue types follow the resulting field types.

If the domain accepts null, replace field(value) with field.nullable(value) or declare a nullable generic such as field<string | null>(''). field<string>(null) remains valid. For configured factories, createFormPrimitives({ nullable: true }) preserves the previous nullable policy for that factory's fields and shorthands; separately created nodes retain their own types. Omitting the option now uses inferred nullability.

field-nullability.example.ts
import { array, field, form } from '@ngblocks/form-nodes';

const profile = form({
name: field('Ada'), // string
nickname: field.nullable(''), // string | null
email: field<string>(null), // string | null
code: field<string>(undefined), // string | undefined
age: field<number>(), // number | null, initially null
roles: array({ name: field('') }),
});

profile.name.set('Grace');
profile.nickname.set(null);
profile.email.set('grace@example.com');
profile.code.set('A');
profile.roles.push({ name: 'admin' });

if (profile.name() !== 'Grace' || profile.nickname() !== null
|| profile.email() !== 'grace@example.com' || profile.code() !== 'A'
|| profile.age() !== null || profile.roles[0]?.name() !== 'admin') {
throw new Error('Fields and aggregate values must preserve their declared value contracts.');
}

profile.code.resetToInitial();
profile.name.reset();
if (profile.code() !== undefined || profile.name() !== 'Grace') {
throw new Error('Reset preserves the current value; resetToInitial restores explicit undefined.');
}

field<T>(undefined) now adds only undefined, producing T | undefined. Use field.nullable<T>(undefined) or a generic containing null if both are needed. Omitted values still start at null: field<T>() produces T | null. Untyped nullish calls still produce FieldNode<unknown>. field.strict() still requires a non-nullish initial value. Non-nullish values incompatible with the generic, such as field<string>(123), remain errors.

Update manually declared payload types and standalone validators to match the intended domain. Keep null in validator types for nullable fields; required does not remove null from a type. Declare nullable number/date fields when their controls can emit null on clearing. The change does not filter runtime writes, alter binding empty values, or change validation or reset rules: reset() preserves the current value and clears interaction state; resetToInitial() restores the original value, including explicit undefined.

Query parameter serializers

Breaking: The deprecated query parameter aliases introduced before 5.0.0 have been removed from @ngblocks/form-nodes/router.

Previous APIReplacement
codec: 'integer'serializer: 'integer'
codec: customSerializerserializer: customSerializer
QueryParamCodec<T>QueryParamSerializer<T>

Rename the option in each syncQueryParams() binding and update type imports and annotations. If a binding already supplies both options, keep only serializer. The queryParam factory names, supported built-in strings, and custom parse/serialize contract are unchanged. Bindings that omit both options continue to infer a serializer from their fallback value.

See syncQueryParams() for the supported configuration and custom serializers for conversion examples.

Moving to 4.0.0: minimum length checks empty text

Breaking: minLength(n) now measures an empty string as length zero, just like an empty collection. With a positive minimum, '' produces a minLength error with actual: 0. minLength(0) still allows ''; null and undefined still pass.

Previously, empty strings bypassed this validator, as they do in Angular Reactive Forms and Signal Forms v22.1.6. Optional text fields initialized or cleared to '' can now make a form invalid and block its submission. Preserve the previous empty-or-long-enough behavior with when:

min-length-optional.example.ts
import { field, form, minLength } from '@ngblocks/form-nodes';

const profile = form({
nickname: field('', [
minLength(3, { when: ({ value }) => value() !== '' }),
]),
});

profile.nickname.valid(); // true: an empty nickname is explicitly allowed
if (!profile.valid()) throw new Error('An optional empty nickname must be valid.');

profile.nickname.set('Al');
profile.nickname.hasError('minLength'); // true
if (!profile.nickname.hasError('minLength')) throw new Error('A populated nickname must meet the minimum.');

profile.nickname.set('Alex');
profile.nickname.valid(); // true
if (!profile.valid()) throw new Error('A sufficiently long nickname must be valid.');

profile.nickname.set('');
profile.nickname.minLength(); // null: the inactive rule also removes its constraint metadata
if (!profile.valid() || profile.nickname.minLength() !== null) {
throw new Error('Clearing an optional nickname must deactivate its length rule.');
}

The condition also removes the rule's constraint metadata while empty. Required text fields can keep [required, minLength(n)]; they remain invalid when empty, but now expose both required and minLength errors. Review error presenters that display every error or rely on counts.

A nullable field initialized with field<string>() starts with null, so minLength alone permits it. Clearing a native text input writes '', which fails a positive minimum. Use required as well when both nullish and empty text values must fail. minLength alone does not set required metadata or the native required attribute.

reset() preserves the committed value, so it preserves an empty string's length error. resetToInitial() restores the initial value: an initial '' fails a positive minimum, while an initial null passes minLength alone. Whitespace is still counted without trimming.

Moving to 3.9.0: boolean presence and acceptance

This breaking change ships in minor version 3.9.0 at the maintainer's explicit request, as an exception to the default Semantic Versioning policy.

required and active requiredIf now accept false. They still reject null, undefined, empty strings, and NaN. Initialize a yes/no answer to null when the user must choose an answer.

For terms, consent, and mandatory checkboxes, replace [required] with [requiredTrue] in the validator list. Replace requiredIf(condition) with requiredTrue({ when: condition }) when acceptance is conditional. Import requiredTrue from @ngblocks/form-nodes.

Update getError('required'), hasError('required'), error templates, and message-catalog entries for those acceptance fields to the requiredTrue kind. Its default message is "This field must be accepted." Existing custom messages can be passed to the new validator.

Both rules keep node.required() and useFormNodeState().required() true while active. Native checkboxes and custom controls with a public checked input receive a required constraint only for acceptance; the latter requires syncInputs to select required. Custom wrappers that manually bind logical required state to an inner native checkbox should follow the checkbox binding guidance.

The new notNil validator rejects only null and undefined. It permits empty strings and NaN and contributes no required metadata. See the validator comparison.

Angular Reactive Forms and Signal Forms controls observed independently through useFormNodeState() keep their own validator semantics. This change applies to Form Nodes validators.

Moving to 3.7.0: closest form state

useClosestFormState() replaces the removed useClosestForm() export. The new hook returns a stable facade rather than a signal of the form API. Its properties are reactive signals:

// Before:
closestForm = useClosestForm();
// After:
formState = useClosestFormState();

Replace closestForm()?.submitted() with formState.submitted(). Replace calls to the old API signal with formState.formNode(): for example, formState.formNode()?.reset() or formState.formNode()?.() for the exposed form value.

submitted() now also supports the nearest Angular Reactive Forms or NgForm root when no Form Nodes owner is available. formNode() remains null for those sources. Without any source, submitted() is false. See resolution and reset timing.

Moving to 3.6.0: read-only validator contexts

Nodes returned by ctx.node(), ctx.field(), and ctx.parent<TParent>() now expose a recursive read-only validation view. This includes $api, ancestors, children, and array traversal. The explicit parent generic preserves your child types but does not restore the full node API. This breaking type change ships in minor version 3.6.0 at the maintainer's explicit request, as an exception to the default Semantic Versioning policy.

Replace validation-output conditions such as ctx.node().valid() or ctx.parent()?.errors() with conditions on the relevant values. Return validation errors, optionally targeting a context node. Move mutations, submissions, and validator replacement into application actions or configure. Code outside validator contexts retains the normal node API. Runtime identities are unchanged.

The optional validator(callback, { reactive: false }) samples external signals on value-triggered validation; it does not make circular reads safe. See the validator reference.

Moving to 3.4.0: one API access path

Use node.$api wherever you previously used the node.api API alias. Direct reads and operations such as node(), node.valid(), and node.reset() continue to work.

Previous API accessCurrent API access
node.apinode.$api
node.api()node.$api()
node.api.valid()node.$api.valid()
node.api.value.control.set(next)node.$api.value.control.set(next)

$api remains callable, reactive, and safe from child-name collisions. A declared child named api is ordinary data: keep access such as profile.api() when it reads that child. Do not replace those child reads with API calls.

This change ships in the minor release 3.4.0 at the maintainer's request while the library has no other consumers. The old alias is removed; update existing API access before upgrading.

Moving to 3.3.0: nested value views

The old public names are removed without compatibility aliases. This incompatible change is included in the minor release 3.3.0. The maintainer has explicitly authorized this versioning exception because the library currently has no other consumers. The migration is still required; a minor version number does not make these removed APIs backward compatible.

Previous APIReplacement
node.controlValue()node.value.control()
node.setControlValue(next)node.value.control.set(next)
node.api.controlValue()node.api.value.control()
node.$api.controlValue()node.$api.value.control()
node.api.setControlValue(next)node.api.value.control.set(next)
node.$api.setControlValue(next)node.$api.value.control.set(next)

When passing a signal to a utility, replace node.controlValue with node.value.control. For extracted actions, use const { set: receiveValue } = node.value.control. Use $api.value for generic AnyNode infrastructure or child-name collisions.

node() and node.value() keep their exposed equality behavior. New value.committed() reads latest committed data before custom equality checks, including in descendants, but does not bypass debounce. value.committed.set(next) is equivalent to node.set(next); node.set() remains supported. Control setters now have a uniform public path on all four node kinds. They receive complete values, mark the selected node dirty, and respect debounce; they do not collect child drafts or emit directive outputs by themselves. The nested signals provide set, not Angular's full WritableSignal interface. See signatures, examples, and semantics.

Moving to 3.0.0: node types and Angular imports

Version 3.0.0 reorganizes public node types and Angular imports. Update imports and explicit type annotations:

Previous APIReplacement
NodeAnyNode
Field<TValue>FieldNode<TValue>
Group<TChildren>GroupNode<TChildren>
Form<TChildren>FormNode<TChildren>
ArrayNode<TItem>Unchanged
FormNode directive import or binding typeFormNodeDirective

FormNode now names the form model type. In component imports, DI, and directive queries, use FormNodeDirective. FormNodeBinding<TNode> remains the generic binding contract. [formNode], the formNode template export, and field(), group(), form(), and array() keep their names and runtime behavior. isFormNode() now narrows to AnyNode.

For components that accept an unspecified node structure, omit the generic arguments and use FieldNode, GroupNode, FormNode, or ArrayNode. FormNode means a form specifically; AnyNode accepts every primitive. See Choosing a node type.

You can import FormNodesModule in components or application modules instead of importing FormNodeDirective individually. It currently exports that directive and does not register configuration providers.

📦 Moving to 2.0.0

Version 2.0.0 is a major release because it includes incompatible API and behavior changes. When upgrading from 1.1.0, follow the 2.0.0 migration sections below. Existing ^1.x dependency ranges do not select 2.0.0; update the dependency explicitly and run your application checks. Incompatible changes normally require another major release; see the explicit 3.3.0 exception.

🔄 2.0.0: standalone validation error types

The ValidationError namespace has been replaced by directly exported types. Update imports and qualified references using this mapping:

Previous referenceImport and use
ValidationError.ForKind<K>ValidationErrorForKind<K>
ValidationError.WithTargetNode<N>ValidationErrorWithTargetNode<N>
ValidationError.WithOptionalTargetNode<N>ValidationErrorWithOptionalTargetNode<N>
ValidationError.WithoutTargetNodeValidationErrorWithoutTargetNode
ValidationError.ValidatorResult<N>ValidatorError<N>

The base ValidationError interface remains available. Object shapes, generic defaults, readonly properties, target restrictions, and validation behavior are unchanged. See Error types.

🧪 2.0.0: experimental input synchronization

Rename syncControlInputs to syncInputs. Optional custom-control input synchronization is now disabled by default. To preserve the old full synchronization, explicitly pass { syncInputs: 'all' } at the node, factory, provider, or global scope. true now means 'declared', not full synchronization. Null resets to false.

Value/checked models, native-control state, CVA callbacks, and interaction hooks retain their normal behavior. A node option configures only its own binding. See the mode reference before enabling this experimental Angular-internal adapter.

⚙️ 2.0.0: global configuration

Replace configureGlobalValidatorMessages(messages) with configureGlobalFormNodes({ validatorMessages: messages }). The old export is removed. The new GlobalFormNodesConfig type additionally accepts classes and syncInputs.

Configure global binding defaults before bootstrap; each explicit Angular provider takes precedence for its own option. Existing bindings retain their captured settings. Global message sources remain reactive and do not receive an injection context. Null resets only the supplied global option, and omission preserves earlier settings.

Cleanup callbacks now restore independent options and skip already cleaned-up overrides when called out of order. See Global configuration.

⚙️ 2.0.0: unified configuration provider

Use provideFormNodesConfig() for both validator messages and binding configuration:

Previous APIReplacement
provideValidatorMessages(factory)provideFormNodesConfig({ validatorMessages: factory })
provideFormNodeConfig(options)provideFormNodesConfig(options)
FormNodeConfigFormNodesConfig

The old exports are removed. Combine the options in one call per injector scope:

provideFormNodesConfig({
validatorMessages: () => ({ required: 'Please complete this field.' }),
classes: ANGULAR_FORMS_STATUS_CLASSES,
syncInputs: false,
});

The function returns Provider[], so it supports component providers as well as application, route, and NgModule providers. Keep injectable factories inside validatorMessages. Messages are still captured when nodes are created; binding an existing node does not replace them.

All three options inherit independently. A synchronization-only configuration preserves inherited classes and messages. A classes-only configuration preserves inherited synchronization and messages. An explicit class map replaces the inherited map without merging. An empty {} registers no providers; { classes: {} } clears only classes. To also restore synchronization explicitly, use { classes: {}, syncInputs: 'all' }. See Configuration provider.

🌳 2.0.0: opt in to dynamic child iteration

forEachChild(callback) now visits only initially declared children. To preserve the previous behavior of including children added with add(), pass the new option:

node.forEachChild(callback, { includeDynamic: true });

The opted-in callback receives DynamicNode, so update callbacks that assumed only declared child types. A runtime boolean also requires a DynamicNode callback. Empty forms and groups need the option to visit any children; their default callback child type is DynamicNode. Object.values(node.children) continues to include added nodes and retains its existing types.

📐 2.0.0: runtime child map types

children again accepts arbitrary string keys, so node.children.nonExisting?.value() is valid. Known properties retain their exact types. Enable noUncheckedIndexedAccess to have missing index-signature keys typed as optional, or use get(key), which is always optional.

Enumeration now includes DynamicNode in its element type because added children may differ from the initial declaration. Use forEachChild() for the precise declared-child union, or forEachChild(callback, { includeDynamic: true }) for all runtime children.

📐 1.1.0: declared-child map types

In version 1.1.0, children exposed only initially declared keys in TypeScript. This restriction is superseded by the 2.0.0 runtime-map change above. Replace dynamic node.children[key] access with node.get(key), which returns DynamicNode | undefined, or retain the precisely typed node returned by add().

Object.values(node.children) now infers the union of declared child types. Runtime enumeration still includes dynamically added nodes, whose types may fall outside that union. forEachChild() now infers the same declared-child union. For arbitrary dynamic child types, use get(key) inside the callback or retain the node returned by add().

This is a breaking typing change; runtime map contents and node behavior are unchanged.

⚙️ Scoped package name

The package is published as @ngblocks/form-nodes. Update dependency declarations, imports, and module augmentations to use that name:

npm install @ngblocks/form-nodes
import { form, field } from '@ngblocks/form-nodes';

Remove the old unscoped dependency if it is present. Exported symbols and form behavior are unchanged. The GitHub repository and documentation site keep their existing URLs.

⚙️ Flattening form submission options

Move submission properties directly into the second form() argument:

Previous optionReplacement
submission.action(form, value)onSubmit(value, form); reverse the callback arguments.
submission.onInvalid(form)onSubmitBlocked(form)
submission.ignoreValidators: 'pending'submitWhen: 'not-invalid' (still the default)
submission.ignoreValidators: 'none'submitWhen: 'valid'
submission.ignoreValidators: 'all'submitWhen: 'always'

Replace FormSubmissionOptions annotations with FormOptions and update their properties. Groups and arrays do not accept any of these submission options. The blocked callback includes pending validation when submitWhen is 'valid'; it does not run for concurrent submissions or missing actions. Pending validation is not awaited. See Form submission.

🔌 Renaming the custom-control state hook

In the first public release, 1.0.0, useControlState() is renamed to useFormNodeState(). Update imports from @ngblocks/form-nodes and every call to the hook. The old name is no longer exported.

The return type remains ControlState<TValue>, and all ControlState* types retain their names. Supported bindings, signal behavior, and injection-context requirements are unchanged; see the useFormNodeState() reference.

🧩 Removing the field adapter

In the first public release, 1.0.0, Form Nodes no longer exposes $field. Change each Form Nodes binding from:

<input [formField]="profile.name.$field" />

To:

<input [formNode]="profile.name" />

Import FormNodeDirective from @ngblocks/form-nodes in the component's imports. Remove Angular's FormField import when no independently created Angular form uses it. Bind native form roots with [formNode]="profile" to retain Form Nodes submission and reset handling.

provideFormNodesConfig() now configures [formNode] only. Angular's provideSignalFormsConfig() configures its own [formField] controls independently; both providers can coexist. If classes previously came from Angular's provider on an adapted control, move them to provideFormNodesConfig() and read state with binding.node() instead of binding.state().

useFormNodeState() remains available for all supported forms APIs. Use Angular's form() and signal() for controls bound through Angular [formField]; see the useFormNodeState() example.

Custom components may implement the Form Nodes control types without depending on Angular's version-specific FormUiControl type. The supported Angular ranges are now ^21.0.7 || ^22.1.5; update older Angular installations to a verified patch before adopting 1.0.0.

🧪 Upgrade checklist

Use this process for every major release:

  1. Read the source and target entries in the changelog.
  2. Verify Angular, Node.js, and TypeScript expectations in Compatibility.
  3. Search the relevant migration section for renamed, removed, or behavior-changing APIs.
  4. Upgrade without suppressing npm peer-dependency warnings.
  5. Run TypeScript and Angular template compilation before changing application code preemptively.
  6. Run tests that cover validation, submission, arrays, and [formNode] control bindings.
npm install --save @ngblocks/form-nodes@^1
npx tsc --noEmit
ng build
ng test

Adapt the verification commands to the scripts and test runner used by your application.

📦 What migration entries will contain

Every breaking migration will identify:

  • The first version containing the change.
  • Who is affected and how to recognize the affected usage.
  • A before-and-after example.
  • Observable behavior changes, not only renamed TypeScript symbols.
  • Any automated migration or temporary compatibility path, when available.

💡 Moving to 1.0.0

1.0.0 is the first public release, so there is no earlier public Form Nodes version to migrate from. The development migration notes on this page apply to users of earlier repository snapshots. For a new application, start with Installation and then build Your first form.

◆ Package name

The library is now named @ngblocks/form-nodes. Replace the previous package dependency with @ngblocks/form-nodes and update imports, re-exports, module augmentations, and any TypeScript path mappings or bundler aliases that reference the previous name.

npm install --save @ngblocks/form-nodes
import { form, field, array } from '@ngblocks/form-nodes';

Exported symbols and form behavior are unchanged by the rename. Remove the previous dependency from package.json and regenerate your lockfile with your package manager.

◆ Validator state access

State signals are no longer direct validator-context properties. Read dirty, disabled, disabledReasons, enabled, hidden, pristine, readonly, required, submitting, touched, untouched, visible, and writable through context.node() or context.field() instead.

For example, replace ({ touched }) => touched() with ({ node }) => node().touched(). The same migration applies to inline validators, validator(), built-in validator when options, and all asyncValidator() callbacks. State reads retain their existing reactive tracking rules. value, node, field, parent, and path remain on the shared context.

◆ Validator node signals and navigation

context.node and context.field are now the same readonly signal returning the validated node. Inline callbacks and inline helpers infer the concrete primitive, its value type, and its children or items. Omit helper generics to allow inference from the enclosing primitive.

Previous accessNew access
context.field as a nodecontext.node() or context.field()
context.field.dirty()context.node().dirty() or context.field().dirty()
context.field() to read a valuecontext.value() (preferred) or context.field().value()
context.form()context.node().form() or context.field().form()
context.root()context.node().root() or context.field().root()

Flat form and root context properties are removed. The node signals never return null and keep their identity across value changes or tree moves. Explicit TField context types appear as Signal<TField> on both aliases. Reading only context.node() tracks identity, not value. context.parent() remains available. Replace context.api with context.node().api or context.field().api. Inline validators infer the concrete node API. For separately declared helpers, provide TField when an exact node type is needed; TApi now only specializes the remaining context navigation. Read the typed value with context.value().

◆ Form and root ancestry lookups

form() now identifies workflow ownership by returning the nearest explicit form(). Code that used it to reach the outermost structural node must call root() instead:

const checkout = form({
payment: form({
card: field(''),
}),
});

checkout.payment.card.form(); // checkout.payment
checkout.payment.card.root(); // checkout

A standalone group() or array() previously returned itself from form() and now returns null; its new root() signal returns itself. Standalone fields continue to return null from form(), but now also expose themselves through root(). Update validator dependencies in the same way: use context.node().form() for the owning workflow and context.node().root() for the complete tree.

◆ Field nullability options

Per-field nullable options were removed before the initial release. Replace field(value, { nullable: false }) with field.strict(value), and replace field(value, { nullable: true }) with field.nullable(value). Preserve any other options as the last argument. The nullable option on createFormPrimitives() is unchanged because it defines a factory-wide default rather than one field's local choice.

◆ Declaration shorthand contract

The initial 1.0.0 contract accepts primitive values, Date, functions, class instances, other non-plain objects, and arrays as atomic field shorthand inside form(), group(), dynamic add(), and object templates passed to array(). Plain objects create structural groups. Existing nodes are attached unchanged.

Arrays are always atomic fields when used as object properties; their length and contents never select the node kind. Replace an array property with array(template) only when its items need independent nodes. Wrap a plain application-data object with field(value) when it must remain one atomic value, or use group({...}) when the structural branch needs options or validators.

Definition objects reject enumerable accessors, symbol child keys, and __proto__. Replace an accessor with a data property before constructing the form, use a supported string child key, or wrap the complete object with field(value) when it represents one leaf value. See the declaration shorthand matrix for exact equivalents and inferred types.

When migrating from Angular Reactive Forms or Angular 22 Signal Forms, use the form-modeling patterns and control-binding guide. These are conceptual migrations rather than version upgrades, so application behavior should be translated deliberately instead of through mechanical symbol replacement.

⚙️ Custom-control binding configuration

Models (value = model(...) or checked = model(...)) and CVAs keep their standard connections without experimental options. State input writes and paired value connections now have independent options, each defaulting to false and inheriting independently.

Earlier configurationReplacement
syncInputs: true or 'only-declared'syncInputs: 'declared'
syncInputs: 'always'syncInputs: 'all'
syncInputs: 'only-signal-controls'syncInputs: 'signal-controls'
{ mode: 'always', inputs: [...] }{ inputs: [...] }
{ mode: 'only-declared', inputs: [...] }Use { inputs: 'declared' } for all declarations, or explicitly list the desired declared inputs.
syncInputs: [] to enable paired valuesbindInputOutputPairs: true with syncInputs false/omitted

Add bindInputOutputPairs: true wherever a separate value/valueChange or checked/checkedChange pair previously relied on syncInputs to connect. Keep syncInputs separately for the desired state inputs. False/null now pauses the entire pair connection, including component focus/reset hooks and writable node access. Returning to an enabled node resynchronizes its current value.

Targets in { inputs, target } filter the selected adapter, not component interfaces. A CVA with a model still matches cva. Active pairs only match all. Model controls can receive complete supported input synchronization with signal-controls, or use useFormNodeState() without experimental writes. See the full configuration reference.