Understanding the Foundations

Spending time with the @angular/forms package has given me a much clearer picture of its internal mechanics. I want to walk you through those insights here.

Outline

Core building blocks

To fully leverage the Forms API, it’s important to understand its fundamental components and how they fit together.

AbstractControl

This core abstract class provides the shared functionality used by FormControl, FormGroup, and FormArray, including:

  • executing validation logic
  • managing UI state transitions – markAsDirty(), markAsTouched(), along with properties like dirty, touched, pristine
  • handling state resets
  • tracking validation outcomes (invalid, valid)

These classes and their derivatives constitute what is known as the model layer – the place where entity-related data resides.

When you arrange multiple AbstractControl instances, you get a tree structure where the terminal nodes are always FormControl instances. The other two types (FormArray, FormGroup) serve as AbstractControl containers, meaning they cannot appear as leaves – each must hold at least one AbstractControl.

// FG - FormGroup
// FA - FormArray
// FC - FormControl

    FG
  /   \
FC    FG
    /    \
  FC     FA
        / | \
      FC FC FC

The tree shown above could be generated from

<form>
  <input type="text" formControlName="companyName">

  <ng-container formGroupName="personal">
    <input type="text" formControlName="name">

    <ng-container formArrayName="hobbies">
      <input type="checkbox" formControlName="0">
      <input type="checkbox" formControlName="1">
      <input type="checkbox" formControlName="2">
    </ng-container>
  </ng-container>
</form>

Details on formArrayName and formGroupName will be covered later.

FormControl

This subclass of AbstractControl inherits all the base capabilities. A key point is that FormControl is associated with exactly one form control – either a DOM element (like <input> or <textarea>) or a custom component (through a ControlValueAccessor, which we'll examine shortly).

When a FormControl is not part of any AbstractControl tree, it is considered standalone. In this state, it functions independently – its value, validity, and user interactions have no bearing on any ancestor form containers (ng-run Example).

FormArray

This derivative of AbstractControl is designed to organize multiple AbstractControl instances as a collection.

In the tree structure, it acts as a node with at least one child. Its validation state, dirtiness, touched state, and value generally mirror the status of its children. However, it's possible for a container to have its own validators, which could introduce errors at that specific node level.

Its distinctive trait is that it holds its child controls in an array.

FormGroup

Similar to FormArray, except its children are stored within an object structure.

AbstractControlDirective

This serves as the parent class for form-control-based directives (NgModel, FormControlName, FormControlDirective) and offers boolean getters that mirror the bound control's state (valid, touched, dirty, and so on).
The associated control gets attached to a DOM element via a specific implementation of AbstractControlDirective (like NgModel or FormControlName) together with a ControlValueAccessor.

Essentially, this class acts as an intermediary, linking the ControlValueAccessor (the view layer) to the AbstractControl (the model layer) – this connection is detailed further ahead.

It's important to note that a single AbstractControl can be bound to several different DOM elements or custom components through multiple AbstractControlDirective instances, each connecting to its own ControlValueAccessor.

Take this scenario as an illustration:

<form>
  <input ngModel name="option" value="value1" type="radio">

  <input ngModel="value3" name="option" value="value2" type="radio">

  <input ngModel="value1" name="option" value="value3" type="radio">
</form>

If you want a particular radio button selected by default, you can assign its value to the final ngModel directive in the template. In the example above, the first radio button ends up checked.

This is due to the fact that the last directive takes precedence when setUpControl() is executed:

export function setUpControl(control: FormControl, dir: NgControl): void {
  if (!control) _throwError(dir, 'Cannot find control with');
  if (!dir.valueAccessor) _throwError(dir, 'No value accessor for form control with');

  /* ... */

  dir.valueAccessor !.writeValue(control.value);
  
  /* ... */
}

ng-run Example.

AbstractFormGroupDirective

This acts as a host for AbstractFormGroupDirective and AbstractControlDirective instances. It proves handy when you need to establish a nested collection of AbstractControls (like address: { city, street, zipcode }) or apply validators across specific AbstractControls (for example, ensuring a min value doesn't surpass a max value).

Its concrete forms include: formGroupName, formArrayName, and ngModelGroup.

<form [formGroup]="filterForm">
  <ng-container formGroupName="price">
    <input formControlName="min" type="text">
    <input formControlName="max" type="text">
  </ng-container>
</form>

Since FormGroupName derives from AbstractFormGroupDirective, it possesses all the characteristics described earlier. It also serves as a container for AbstractControl instances.
However, a top-level container must be an Angular FormGroup (see this resource). Consequently, employing FormGroupName at the root level will lead to an error.

AbstractFormGroupDirective offers a pathway to reach the top-level FormGroup instance:

get formDirective(): Form|null { return this._parent ? this._parent.formDirective : null; }

In this context, this._parent could be another AbstractFormGroupDirective or a FormGroupDirective. Note that FormGroupDirective itself lacks a _parent property.

ControlValueAccessor

The ControlValueAccessor is a cornerstone of the Forms API, representing the view layer.

Its role is to bridge a DOM element (like <input> or <textarea>) or a custom component (for instance, <app-custom-input>) with an AbstractControlDirective (such as NgModel or FormControlName). The AbstractControlDirective then serves as the conduit between ControlValueAccessor (the view) and AbstractControl (the model), enabling two-way communication.

Consider these typical flows:

  • a user enters text in an input field: View -> Model
  • a value is assigned programmatically via FormControl.setValue('newValue'): Model -> View

Only FormControl instances engage directly with a ControlValueAccessor. This is because, within an AbstractControl tree, FormControls are always the terminal nodes, as they aren't meant to contain other nodes. From this, it follows that view-originated updates always begin at the leaf level.

// FG - FormGroup
// FA - FormArray
// FC - FormControl
                                  FG
                                /   \
user typing into an input  <- FC    FA
                                   / | \
                                FC  FC  FC <- user selecting checkbox

The ControlValueAccessor interface is defined as follows:

export interface ControlValueAccessor {
  writeValue(obj: any): void;

  registerOnChange(fn: any): void;

  registerOnTouched(fn: any): void;

  setDisabledState?(isDisabled: boolean): void;
}
  • writeValue() – pushes a new value to the element; this value originates from the MODEL (path: FormControl.setValue -> ControlValueAccessor.writeValue -> element update -> UI reflects change)
  • registerOnChange() – records a callback function to be executed whenever the value changes in the UI, so the new value can be transmitted to the model.
  • registerOnTouched() – records a callback function triggered on a blur event; the associated FormControl gets notified, as it might need to react to this occurrence.
  • setDisabledState()enables or disables the DOM element based on the given value; this is typically invoked when there's a change in the MODEL.

These methods are put into practice in the upcoming section: Connecting FormControl with ControlValueAccessor.

There are three varieties of ControlValueAccessors:

default

@Directive({
selector:
    'input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]',
})
export class DefaultValueAccessor implements ControlValueAccessor { }

built-in

const BUILTIN_ACCESSORS = [
  CheckboxControlValueAccessor,
  RangeValueAccessor,
  NumberValueAccessor,
  SelectControlValueAccessor,
  SelectMultipleControlValueAccessor,
  RadioControlValueAccessor,
];

Additional details on built-in accessors can be found in Exploring built-in ControlValueAccessors.

custom – for integrating a custom component into the AbstractControl tree

@Component({
  selector: 'app-custom-component',
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: CustomInputComponent,
      multi: true,
    }
  ]
  /* ... */
})
export class CustomInputComponent implements ControlValueAccessor { }
<form>
  <app-custom-component ngModel name="name"></app-custom-component>
</form>

Keep in mind that ngModel is a form-control-based directive, so it acts as an intermediary between a ControlValueAccessor (the view) and a FormControl (the model).

Bridging FormControl and ControlValueAccessor

As noted, the AbstractControlDirective is the essential link that allows the view layer (via ControlValueAccessor) to communicate with the model layer (specifically, FormControl), and the other way around.

The interaction looks like this:

  --------------------------
  |                        |
  |  ControlValueAccessor  |  <--- View Layer
  |                        |
  -------------------------- 
    |
    |                 |
    |                 |
|
------------------------------ 
|                            |
|  AbstractControlDirective  |
|                            |
------------------------------ 
        |
        |           |
        |           |
|
      ----------------- 
      |               |
      |  FormControl  |  <--- Model Layer
      |               |
      ----------------- 

The symbol marks the ViewToModelPipeline, while designates the ModelToViewPipeline.

AbstractControlDirective is pivotal in this process. Let's take a closer look at the implementation!

The diagram above corresponds to the following code:

Note: In practice, NgControl extends AbstractControlDirective and primarily serves as a factory for form-control-based directives like NgModel or FormControlName, but it contains no concrete method bodies.

The setUpControl function gets invoked each time a form-control-based directive comes to life.

export function setUpControl(control: FormControl, dir: NgControl): void {
  if (!control) _throwError(dir, 'Cannot find control with');
  if (!dir.valueAccessor) _throwError(dir, 'No value accessor for form control with');

  control.validator = Validators.compose([control.validator !, dir.validator]);
  control.asyncValidator = Validators.composeAsync([control.asyncValidator !, dir.asyncValidator]);
  dir.valueAccessor !.writeValue(control.value);

  setUpViewChangePipeline(control, dir);
  setUpModelChangePipeline(control, dir);

  setUpBlurPipeline(control, dir);

  /* ... Skipped for brevity ... */
}

// VIEW -> MODEL
function setUpViewChangePipeline(control: FormControl, dir: NgControl): void {
  dir.valueAccessor !.registerOnChange((newValue: any) => {
    control._pendingValue = newValue;
    control._pendingChange = true;
    control._pendingDirty = true;

    if (control.updateOn === 'change') updateControl(control, dir);
  });
}

// Update the MODEL based on the VIEW's value
function updateControl(control: FormControl, dir: NgControl): void {
  if (control._pendingDirty) control.markAsDirty();
  
  // `{emitModelToViewChange: false}` will make sure that `ControlValueAccessor.writeValue` won't be called
  // again since the value is already updated, because this change comes from the view
  control.setValue(control._pendingValue, {emitModelToViewChange: false});

  // If you have something like `<input [(ngModel)]="myValue">`
  // this will allow `myValue` to be the new value that comes from the view
  dir.viewToModelUpdate(control._pendingValue);

  control._pendingChange = false;
}

// MODEL -> VIEW
function setUpModelChangePipeline(control: FormControl, dir: NgControl): void {
  control.registerOnChange((newValue: any, emitModelEvent: boolean) => {
    // control -> view
    dir.valueAccessor !.writeValue(newValue);

    // control -> ngModel
    if (emitModelEvent) dir.viewToModelUpdate(newValue);
  });
}

For reference, here is the ControlValueAccessor interface once more:

export interface ControlValueAccessor {
  writeValue(obj: any): void;

  registerOnChange(fn: any): void;

  registerOnTouched(fn: any): void;

  setDisabledState?(isDisabled: boolean): void;
}

The setUpViewChangePipeline method is how the AbstractControlDirective (passed as dir) establishes a unidirectional link from the view to the model. It achieves this by attaching a callback function to ControlValueAccessor.onChange, ensuring that any UI activity is mirrored in the model.

Here's a concrete example of how ControlValueAccessor.registerOnChange is implemented:

@Directive({
  selector: 'input[custom-value-accessor][type=text][ngModel]',
  host: {
    '(input)': 'onChange($event.target.value)',
  }
})
export class CustomValueAccessor {
  registerOnChange(fn: (_: any) => void): void { this.onChange = fn; }
}

Conversely, setUpModelChangePipeline enables the AbstractControlDirective to connect the model to the view. So, whenever FormControl.setValue() is called, every callback function registered with that FormControl gets executed to refresh the view based on the latest model value.

Notice the emphasis on every callback function. This is because multiple AbstractControlDirective instances can share a single FormControl object.

// Inside `FormControl`
_onChange: Function[] = [];
registerOnChange(fn: Function): void { this._onChange.push(fn); }
// FormControl.setValue
setValue(value: any, options: {
  onlySelf?: boolean,
  emitEvent?: boolean,
  emitModelToViewChange?: boolean,
  emitViewToModelChange?: boolean
} = {}): void {
  (this as{value: any}).value = this._pendingValue = value;
  if (this._onChange.length && options.emitModelToViewChange !== false) {
    this._onChange.forEach(
        (changeFn) => changeFn(this.value, options.emitViewToModelChange !== false));
  }
  this.updateValueAndValidity(options); // Update ancestors
}

For instance:

<form>
  <input type="radio" ngModel name="genre" value="horror">
  <input type="radio" ngModel name="genre" value="comedy">
</form>

Here, setUpControl(control, dir) fires twice – once for each ngModel. But in each call, the control (a FormControl) argument remains the same. Consequently, control.onChanges accumulates two callback functions, one for each ControlValueAccessor (since <input type="radio"> is paired with RadioControlValueAccessor).

On a related note, ControlValueAccessor.registerOnTouched operates similarly to ControlValueAccessor.registerOnChange:

// Called inside `setUpControl`
function setUpBlurPipeline(control: FormControl, dir: NgControl): void {
  dir.valueAccessor !.registerOnTouched(() => {
    control._pendingTouched = true;

    if (control.updateOn === 'blur' && control._pendingChange) updateControl(control, dir);
    if (control.updateOn !== 'submit') control.markAsTouched();
  });
}

This mechanism permits the model to be refreshed when a blur event occurs in the view.

Back to Contents.


Template-Driven and Reactive Forms Compared

Both paradigms are highly capable, yet Reactive Forms tend to be the better choice when dealing with intricate or adaptive logic.

Template-Driven Forms

In this approach, the bulk of the form-shaping logic resides in the view. Consequently, the AbstractControl tree is constructed simultaneously with the rendering of the template.

The following utilities are available when opting for the template-driven method:

export const TEMPLATE_DRIVEN_DIRECTIVES: Type<any>[] =
    [NgModel, NgModelGroup, NgForm];

NgModel

This directive, which is based on form controls, serves as a two-way link between the UI layer and the data layer (specifically a FormControl). It also handles the registration of this FormControl within the overarching AbstractControl tree.

Certain configuration options can be provided when using this directive:

@Input('ngModelOptions')
  options !: {name?: string, standalone?: boolean, updateOn?: 'change' | 'blur' | 'submit'};

For those instances where you require a detached FormControl instance, the following pattern applies:

<form #f="ngForm">
  <input [ngModelOptions]="{ standalone: true }" #myNgModel="ngModel" name="name" ngModel type="text">
</form>

{{ myNgModel.value }}

<br>

{{ f.value | json }}

ng-run Example.

NgModelGroup

This directive enables the clustering of several NgModel and NgModelGroup directives. On the model side, it manifests as a nested FormGroup instance.
Similar to NgModel, it is responsible for adding this FormGroup to the AbstractControl tree.

<form> <!-- `NgForm` - automatically bound to `<form>` -->
  <input type="text" ngModel name="companyName"/>

  <div ngModelGroup="personal">
    <input type="text" ngModel name="name"/>

    <div ngModelGroup="address">
      <input type="text" ngModel name="city"/>
      <input type="text" ngModel name="street" />
    </div>
  </div>
</form>

It is a requirement that the initial NgModelGroup appears as a direct descendant of NgForm:

<!-- Valid -->
<form>
  <ng-container #myGrp="ngModelGroup" ngModelGroup="address">
    <input type="text"ngModel name="city" />
    <input type="text" ngModel name="street">
  </ng-container>
</form>
<!-- Invalid: `No provider for ControlContainer ...` -->
<div #myGrp="ngModelGroup" ngModelGroup="address">
  <input type="text"ngModel name="city" />
  <input type="text" ngModel name="street">
</div>

NgForm

This directive serves to aggregate multiple NgModel and NgModelGroup directives. Represented as a root-level instance in the model, it is responsible for handling form-specific actions like reset and submit. It is also automatically associated with <form> elements.

In the model layer, this corresponds to the topmost FormGroup within the AbstractControl tree.

<form> <!-- NgForm -->
  <input ngModel name="companyName" type="text"> <!-- NgModel -->

  <div ngModelGroup="address"> <!-- NgModelGroup -->
    <input ngModel name="city" type="text"> <!-- NgModel -->
    <input ngModel name="street" type="text"> <!-- NgModel -->
  </div>
</form>

Reactive Forms

Contrary to the template-driven strategy, with Reactive Forms the form structure is fully established prior to the view being rendered.

The subsequent tools are at your disposal when following this reactive methodology:

export const REACTIVE_DRIVEN_DIRECTIVES: Type<any>[] =
    [FormControlDirective, FormGroupDirective, FormControlName, FormGroupName, FormArrayName];

FormControlDirective

This form-control-based directive acts as the intermediary connecting the two primary layers: the view and the model.

It accepts a FormControl instance via [formControl]="formControlInstance", which is already integrated, as formControlInstance is already part of an existing AbstractControl tree. Thus, the sole remaining task is to link formControlInstance to the relevant DOM element utilizing the designated value accessor.

For using a standalone FormControl instance, the following approach is applicable:

<input #f="ngForm" [formControl]="formControlInstance" type="text">

{{ f.value }}

FormGroupDirective

This directive corresponds to a root-level FormGroup instance (<form [formGroup]="formGroupInstance">). Being at the top level, it is wired to handle form-specific events, including reset and submit. The formGroupInstance here serves as the head of a pre-constructed AbstractControl tree.

FormControlName

This directive takes a string input ([formControlName]="nameOfFormControlInstance"). Its purpose is to locate the specific FormControl instance based on the given control name (nameOfFormControlInstance) and its context within the view. If a matching FormControl cannot be located along the computed path, an error is raised.

Consequently, nameOfFormControlInstance must be a legitimate name, as the directive depends on the parent form container to correctly situate the FormControl within the AbstractControl tree.

As previously stated, the path is deduced from the location of the DOM element (or custom component) in conjunction with nameOfFormControlInstance:

// control - is, in this case, the top level `FormGroup` instance
function _find(control: AbstractControl, path: Array<string|number>| string, delimiter: string) {
  if (path == null) return null;

  if (!(path instanceof Array)) {
    path = (<string>path).split(delimiter);
  }
  if (path instanceof Array && (path.length === 0)) return null;

  return (<Array<string|number>>path).reduce((v: AbstractControl | null, name) => {
    if (v instanceof FormGroup) {
      return v.controls.hasOwnProperty(name as string) ? v.controls[name] : null;
    }

    if (v instanceof FormArray) {
      return v.at(<number>name) || null;
    }

    return null;
  }, control);
}
<form [formGroup]="myFormGroup">
  <!-- path: 'name' -->
  <input formControlName="name" type="text">

  <!-- path: 'address' -->
  <ng-container formGroupName="address">
    <!-- path: ['address', 'city'] -->
    <input formControlName="city" type="text">

    <!-- path: ['address', 'street'] -->
    <input formControlName="street" type="text">
  </ng-container>
</form>

This illustrates the process for determining the path for each directive:

export function controlPath(name: string, parent: ControlContainer): string[] {
  return [...parent.path !, name];
}

An important limitation is that nameOfFormControlInstance must remain static. Once the FormControl has been registered, it cannot be swapped dynamically. (Example)

The reasoning behind this is:

@Directive({selector: '[formControlName]', providers: [controlNameBinding]})
export class FormControlName extends NgControl implements OnChanges, OnDestroy {
  /* ... */
  ngOnChanges(changes: SimpleChanges) {
    if (!this._added) this._setUpControl();
  }

  private _setUpControl() {
    this._checkParentType();

    // formDirective - points to the top-level `FormGroup` instance
    (this as{control: FormControl}).control = this.formDirective.addControl(this);
    if (this.control.disabled && this.valueAccessor !.setDisabledState) {
      this.valueAccessor !.setDisabledState !(true);
    }
    this._added = true;
  }
  /* ... */
}

Nevertheless, if you need to bind to a different FormControl instance whenever nameOfFormControlInstance changes, a workaround is available:

{FormArray|FormGroup}.setControl(ctrlName, formControlInstance)

FormGroupName

Given a string input ([formGroupName]="nameOfFormGroupInstance"), this directive is tasked with locating the appropriate FormGroup instance based on that string.

It is not suitable for use as a root form container; it must be nested within an existing FormGroupDirective.

Let's assume a form model structured like this:

const address = this.fb.group({
  street: this.fb.control(''),
});

this.form = this.fb.group({
  name: this.fb.control(''),
  address,
});

Attempting to render this in the view will produce an error (Cannot find control with name: 'street'):

<form #f="ngForm" [formGroup]="form">
  <input formControlName="name" type="text">

  <input formControlName="street" type="text">
</form>

The solution is to employ the FormGroupName directive to define a sub-group, thereby aligning the view structure with the model.

<form #f="ngForm" [formGroup]="form">
  <input formControlName="name" type="text">

  <ng-container formGroupName="address">
    <input formControlName="street" type="text">
  </ng-container>
</form>

{{ f.value | json }}

It's worth noting that this step is unnecessary when utilizing FormControlDirective ([formControl]="formControlInstance"), because the directive bypasses the lookup process since it is handed the FormControl instance directly.

FormArrayName

This directive functions analogously to FormGroupName, with the distinction that it seeks out an existing FormArray instance within the AbstractControl tree.

this.fooForm = this.fb.group({
  movies: this.fb.array([
    this.fb.control('action'),
    this.fb.control('horror'),
    this.fb.control('mistery'),
  ]),
});
<form #f="ngForm" [formGroup]="fooForm">
  <ng-container formArrayName="movies">
    <input
      *ngFor="let _ of fooForm.controls['movies'].controls; let idx = index;"
      [formControlName]="idx"
      type="text"
    >
  </ng-container>
</form>

{{ f.value | json }}

Back to Contents.


Validation Logic

Validators provide a means to impose restrictions on AbstractControl instances (FormControl, FormArray, FormGroup).

These validators are configured and executed upon the initialization of the AbstractControl tree. For post-initialization adjustments, the AbstractFormControl.setValidators and AbstractFormControl.setAsyncValidators methods can be used to assign them, while AbstractFormControl.updateValueAndValidity triggers their execution.

setValidators(newValidator: ValidatorFn|ValidatorFn[]|null): void {
  this.validator = coerceToValidator(newValidator);
}

updateValueAndValidity(opts: {onlySelf?: boolean, emitEvent?: boolean} = {}): void {
  /* ... */

  if (this.enabled) {
    this._cancelExistingSubscription();
    // Run sync validators
    // and will invoke `this.validator`
    (this as{errors: ValidationErrors | null}).errors = this._runValidator();
    // If `errors` property is not null -> status = 'INVALID'
    (this as{status: string}).status = this._calculateStatus();

    if (this.status === VALID || this.status === PENDING) {
      this._runAsyncValidator(opts.emitEvent);
    }
  }

  /* ... */

  if (this._parent && !opts.onlySelf) {
    this._parent.updateValueAndValidity(opts);
  }
}

The code excerpt above also reveals that asynchronous validators remain inactive if the synchronous validators have produced errors.

Applying Predefined Validators

Angular's built-in validators are accessible either as directives or as static methods on the Validator class.

To illustrate, the email validator can be applied directly in the template:

<form>
  <input email ngModel name="email" type="text">
</form>
@Directive({
  selector: '[email][formControlName],[email][formControl],[email][ngModel]',
  providers: [EMAIL_VALIDATOR]
})
export class EmailValidator implements Validator {
  /* ... */

  validate(control: AbstractControl): ValidationErrors|null {
    return this._enabled ? Validators.email(control) : null;
  }

  /* ... */
}

In contrast, with Reactive Forms, the usage would be:

this.form = new FormGroup({
  name: new FormControl(defaultValue, [Validators.Email])
})

Even though validators are typically defined in the component class for Reactive Forms, they can still be supplied through the template; upon creation of the AbstractControl instance, these validators are ultimately integrated within the setUpControl function.

// dir.validator - sync validators provided via directives(eg: `<input email type="text">`)
// control.validator - sync validators provided through `Reactive Forms`(eg: new FormControl('', [syncValidators]))
export function setUpControl(control: FormControl, dir: NgControl): void {
  if (!control) _throwError(dir, 'Cannot find control with');
  if (!dir.valueAccessor) _throwError(dir, 'No value accessor for form control with');

  control.validator = Validators.compose([control.validator !, dir.validator]);
  control.asyncValidator = Validators.composeAsync([control.asyncValidator !, dir.asyncValidator]);
  
  /* ... */
}

Merging and Combining Validators

Validators can originate from various sources: the template, the component class, or a combination of both.

These individual validators are ultimately consolidated into a singular function. When this unified function is invoked, it runs each validator sequentially, aggregating any errors they return.

Those validators that conform to the Validator interface are first standardized. This involves converting them into a function that calls their Validator.validate method:

export function normalizeValidator(validator: ValidatorFn | Validator): ValidatorFn {
  if ((<Validator>validator).validate) {
    return (c: AbstractControl) => (<Validator>validator).validate(c);
  } else {
    return <ValidatorFn>validator;
  }
}

The assignment and combination (when required) of validators occurs inside the setUpControl function:

export function setUpControl(control: FormControl, dir: NgControl): void {
  if (!control) _throwError(dir, 'Cannot find control with');
  if (!dir.valueAccessor) _throwError(dir, 'No value accessor for form control with');

  control.validator = Validators.compose([control.validator !, dir.validator]);
  control.asyncValidator = Validators.composeAsync([control.asyncValidator !, dir.asyncValidator]);
  
  /* ... */
}

Let's examine the inner workings of Validators.compose:

export class Validators {
  static compose(validators: (ValidatorFn|null|undefined)[]|null): ValidatorFn|null {
    if (!validators) return null;
    const presentValidators: ValidatorFn[] = validators.filter(isPresent) as any;
    if (presentValidators.length == 0) return null;

    return function(control: AbstractControl) {
      return _mergeErrors(_executeValidators(control, presentValidators));
    };
  }
}

function _executeValidators(control: AbstractControl, validators: ValidatorFn[]): any[] {
  return validators.map(v => v(control));
}

// Accumulate errors
function _mergeErrors(arrayOfErrors: ValidationErrors[]): ValidationErrors|null {
  const res: {[key: string]: any} =
      arrayOfErrors.reduce((res: ValidationErrors | null, errors: ValidationErrors | null) => {
        return errors != null ? {...res !, ...errors} : res !;
      }, {});
  return Object.keys(res).length === 0 ? null : res;
}

Validator.composeAsync follows a similar principle, but with a different execution model. It first transforms each async validator into an observable, then runs them concurrently using the forkJoin operator.

export class Validators {
  static composeAsync(validators: (AsyncValidatorFn|null)[]): AsyncValidatorFn|null {
    if (!validators) return null;
    const presentValidators: AsyncValidatorFn[] = validators.filter(isPresent) as any;
    if (presentValidators.length == 0) return null;

    return function(control: AbstractControl) {
      const observables = _executeAsyncValidators(control, presentValidators).map(toObservable);
      return forkJoin(observables).pipe(map(_mergeErrors));
    };
  }
}

Building Custom Validation Rules

A common pattern for constructing a custom validator involves creating a directive that implements the Validator interface:

// min-max-validator.directive.ts
@Directive({
  selector: '[min-max-validator]',
  providers: [
    {
      provide: NG_VALIDATORS,
      useExisting: forwardRef(() => MinMaxValidator),
      multi: true,
    }
  ]
})
export class MinMaxValidator implements Validator {

  constructor() { }

  validate (f: FormGroup): ValidationErrors | null {
    if (f.pristine) {
      return null;
    }

    const { min, max } = f.controls;

    // `min` or `max` is not a number or is empty
    if (min.invalid || max.invalid) {
      return null;
    }

    if (+min.value >= +max.value) {
      return { minGreaterMax: 'min cannot be greater than max!' };
    }

    return null;
  }
}
<form #f="ngForm">
  <ng-container min-max-validator ngModelGroup="price" #priceGrp="ngModelGroup">
    <input type="text" ngModel name="min" pattern="^\d+$" required />
    <input type="text" ngModel name="max" pattern="^\d+$" required >
  </ng-container>
</form>

ng-run Example

Evaluating Dynamic Validators

This is the structure of the Validator interface:

export interface Validator {
  validate(control: AbstractControl): ValidationErrors|null;

  registerOnValidatorChange?(fn: () => void): void;
}

The registerOnValidatorChange method allows the registration of a callback function, which is invoked whenever the validator's input properties change. Calling this callback ensures that your AbstractControl instance reflects the latest validator state.

Scenario: <input [required]="true"> –> <input [required]="false">

@Directive({
selector:
    ':not([type=checkbox])[required][formControlName],:not([type=checkbox])[required][formControl],:not([type=checkbox])[required][ngModel]',
providers: [REQUIRED_VALIDATOR],
host: {'[attr.required]': 'required ? "" : null'}
})
export class RequiredValidator implements Validator {
  set required(value: boolean|string) {
    this._required = value != null && value !== false && `${value}` !== 'false';
    if (this._onChange) this._onChange();
  }

  registerOnValidatorChange(fn: () => void): void { this._onChange = fn; }
}
export function setUpControl(control: FormControl, dir: NgControl): void {
  /* ... */
  
  // re-run validation when validator binding changes, e.g. minlength=3 -> minlength=4
  dir._rawValidators.forEach((validator: Validator | ValidatorFn) => {
    if ((<Validator>validator).registerOnValidatorChange)
      (<Validator>validator).registerOnValidatorChange !(() => control.updateValueAndValidity());
  });

  dir._rawAsyncValidators.forEach((validator: AsyncValidator | AsyncValidatorFn) => {
    if ((<Validator>validator).registerOnValidatorChange)
      (<Validator>validator).registerOnValidatorChange !(() => control.updateValueAndValidity());
  });

  /* ... */
}

ng-run Example.

Back to Contents.


Inside Angular's Default ControlValueAccessor Implementations

Angular ships with a set of pre-built value accessors ready to use:

const BUILTIN_ACCESSORS = [
  CheckboxControlValueAccessor,
  RangeValueAccessor,
  NumberValueAccessor,
  SelectControlValueAccessor,
  SelectMultipleControlValueAccessor,
  RadioControlValueAccessor,
];

The following sections dive into how several of these default accessors work under the hood.

The SelectValueAccessor Directive

This accessor supports two binding approaches: [value] for simple types or [ngValue] for any type.

Binding with <option [value]="primitiveValue">

The primitiveValue variable is restricted to primitive data types. For object binding, [ngValue] is required.

Every <option> gets its value attribute assigned to primitiveValue.

@Input('value')
set value(value: any) {
  this._setElementValue(value);
}

_setElementValue(value: string): void {
  this._renderer.setProperty(this._element.nativeElement, 'value', value);
}

ng-run Example.

Binding with <option [ngValue]="primitiveOrNonPrimitiveValue">

This approach is more flexible. It handles both primitive values and objects without limitations.

The value assigned to the <option> will directly correspond to the argument passed to [ngValue].

@Input('ngValue')
  set ngValue(value: any) {
    if (this._select == null) return;
    this._select._optionMap.set(this.id, value);
    this._setElementValue(_buildValueString(this.id, value));
    this._select.writeValue(this._select.value);
}

/* ... */

function _buildValueString(id: string | null, value: any): string {
  if (id == null) return `${value}`;
  if (value && typeof value === 'object') value = 'Object';
  return `${id}: ${value}`.slice(0, 50);
}

Observe that providing an object, the resulting value looks like '1: Object'. Supplying a primitive, such as a city name, leads to a value form 0: 'NY'.

It is worth noting that when programmatically setting the select's value via FormControl.setValue(arg), if arg is an object, this exact object must be the same instance that is passed to <option [ngValue]="arg"></option>. Internally, SelectControlValueAccessor.writeValue(obj) relies on the === operator to determine the selected option.

writeValue(value: any): void {
    this.value = value;
    const id: string|null = this._getOptionId(value); // <---- Here!
    if (id == null) {
      this._renderer.setProperty(this._elementRef.nativeElement, 'selectedIndex', -1);
    }
    const valueString = _buildValueString(id, value);
    this._renderer.setProperty(this._elementRef.nativeElement, 'value', valueString);
}

_getOptionId(value: any): string|null {
  for (const id of Array.from(this._optionMap.keys())) {
    if (this._compareWith(this._optionMap.get(id), value)) return id;
  }

  return null;
}

By default, the _compareWith function is defined as:

return a === b || typeof a === 'number' && typeof b === 'number' && isNaN(a) && isNaN(b);

This StackBlitz demo illustrates using a custom _compareWith function:

compareWith(existing, toCheckAgainst) {
  if (!toCheckAgainst) {
    return false;
  }
  return existing.id === toCheckAgainst.id;
}
<!-- 
  1) Try without '[compareWith]="compareWith"'
  2) select another option(`B`, or `C`)
  3) click `change`

  You should not see the value updated inside the `<select>`
  and that is because the default impl. of `compareWith` will compare the values with `===`
-->
<select
  #s="ngModel"
  [ngModel]="selectedItem"
  [compareWith]="compareWith"
>
  <option
    *ngFor="let item of items"
    [ngValue]="item"
  >
    {{item.name}}
  </option>
</select>

<br><br>

<button (click)="s.control.setValue({ id: '1', name: 'A' })">change</button>

Refer to this test case to see the expected behavior.

The SelectMultipleValueAccessor Directive

This accessor maintains an internal _optionMap to track each option. When a change event fires on the <select>, it uses this map to associate the selected options (event.target.selectedOptions) back to the model by pulling the correct bound values (from [value] or [ngValue]).

// _ - the select element
this.onChange = (_: any) => {
  const selected: Array<any> = [];
  if (_.hasOwnProperty('selectedOptions')) {
    const options: HTMLCollection = _.selectedOptions;
    for (let i = 0; i < options.length; i++) {
      const opt: any = options.item(i);
      const val: any = this._getOptionValue(opt.value);
      selected.push(val);
    }
  }

  this.value = selected;
  fn(selected);
};

Similarly, when the FormControl linked to the <select> is updated programmatically (calling FormControl.setValue()), the accessor must ascertain which existing options correspond to the newly supplied values.

writeValue(value: any): void {
  this.value = value;
  let optionSelectedStateSetter: (opt: ɵNgSelectMultipleOption, o: any) => void;
  if (Array.isArray(value)) {
    // convert values to ids
    const ids = value.map((v) => this._getOptionId(v));
    optionSelectedStateSetter = (opt, o) => { opt._setSelected(ids.indexOf(o.toString()) > -1); };
  } else {
    optionSelectedStateSetter = (opt, o) => { opt._setSelected(false); };
  }
  this._optionMap.forEach(optionSelectedStateSetter);
}

The RadioValueAccessor Directive

Managing radio buttons relies on a dedicated internal service named RadioControlRegistry. This registry stores an array pairing an NgControl with a RadioValueAccessor. Here, NgControl is an abstraction that points to directives like NgModel, FormControl, or FormControlName.

The mechanism works as follows:

@Injectable()
export class RadioControlRegistry {
  private _accessors: any[] = [];

  add(control: NgControl, accessor: RadioControlValueAccessor) {
    this._accessors.push([control, accessor]);
  }

  remove(accessor: RadioControlValueAccessor) {
    for (let i = this._accessors.length - 1; i >= 0; --i) {
      if (this._accessors[i][1] === accessor) {
        this._accessors.splice(i, 1);
        return;
      }
    }
  }

  select(accessor: RadioControlValueAccessor) {
    this._accessors.forEach((c) => {
      if (this._isSameGroup(c, accessor) && c[1] !== accessor) {
        c[1].fireUncheck(accessor.value);
      }
    });
  }

  private _isSameGroup(
      controlPair: [NgControl, RadioControlValueAccessor],
      accessor: RadioControlValueAccessor): boolean {
    if (!controlPair[0].control) return false;
    return controlPair[0]._parent === accessor._control._parent &&
        controlPair[1].name === accessor.name;
  }
}

Pay particular attention to the RadioControlRegistry._isSameGroup method.

Consider this minimal example:

<form>
  <input ngModel name="option" value="value1" type="radio"> <!-- #1 NgModel._parent = the top-level `FormGroup` which results from `<form>` -->

  <ng-container ngModelGroup="foo">
    <input ngModel name="option" value="value1" type="radio"> <!-- #2 NgModel._parent = the sub-group `FormGroup` which results from `ngModelGroup` -->
  </ng-container>
</form>

Keep in mind, these two radio buttons share an identical value!

The resulting RadioControlRegistry._accessors array would be structured as:

[
  NgControl(-> NgModel) /* #1 */, RadioControlValueAccessor,
  NgControl(-> NgModel) /* #2 */, RadioControlValueAccessor,
]

Upon user clicking the first radio button, the registry executes this method:

select(accessor: RadioControlValueAccessor) {
  this._accessors.forEach((c) => {
    if (this._isSameGroup(c, accessor) && c[1] !== accessor) {
      c[1].fireUncheck(accessor.value);
    }
  });
}

In this context, accessor is the RadioControlValueAccessor assigned to that first button.

Let’s re-examine the _isSameGroup logic:

private _isSameGroup(
    controlPair: [NgControl, RadioControlValueAccessor],
    accessor: RadioControlValueAccessor): boolean {
  if (!controlPair[0].control) return false;
  return controlPair[0]._parent === accessor._control._parent &&
      controlPair[1].name === accessor.name;
}

The condition controlPair[0]._parent === accessor._control._parent is key; it ensures interaction with the first button doesn't inadvertently impact the second.

In the example below, selecting the second button will cause the first button to be checked.

<form>
  <input ngModel name="option" value="value1" type="radio">

  <input ngModel name="option" value="value1" type="radio">
</form>

This occurs because, among N radio buttons sharing both name and value, only one can be selected. The last button that satisfies the criteria:

this._isSameGroup(c, accessor) && c[1] !== accessor

is chosen, with accessor referring to the RadioControlValueAccessor of the currently clicked button.

ng-run Example.

Back to Contents.


Decoding the AbstractControl hierarchy

Throughout this series, the term AbstractControl tree has been used frequently. To clarify, AbstractControl serves as the base abstract class, with FormControl, FormGroup, and FormArray as its concrete subclasses.

To make this concept more tangible, it helps to visualize the relationships as a hierarchical tree.

Take the following structure:

new FormGroup({
  name: new FormControl(''),
  address: new FormGroup({
    city: new FormControl(''),
    street: new FormControl(''),
  }),
});

This can be represented visually as:

   FG
  /  \
 FC  FG
    /  \
   FC  FC

With this diagram in mind, we can examine how different AbstractControl operations—such as reset(), submit(), or markAsDirty()—affect the entire tree.

Before proceeding, it might be helpful to review the foundational entities discussed earlier.

Inside _pendingDirty, _pendingValue, and _pendingChange

These three private attributes of AbstractControl are often overlooked, yet they are crucial for the efficient operation of the control tree.

These properties are particularly relevant for FormControl instances because they depend on data passed from the view via the ControlValueAccessor.

Understanding _pendingChange

This flag tracks whether the user has actually modified the value of a FormControl.

For instance, consider an <input ngModel name="name" type="text">. When the user types, the ControlValueAccessor triggers its onChange callback, which is defined as follows:

function setUpViewChangePipeline(control: FormControl, dir: NgControl): void {
  dir.valueAccessor !.registerOnChange((newValue: any) => {
    control._pendingValue = newValue;
    control._pendingChange = true;
    control._pendingDirty = true;

    if (control.updateOn === 'change') updateControl(control, dir);
  });
}

Setting control._pendingChange = true signals that the user has directly interacted with the input field.

This is particularly handy when the update strategy is not the default. The default strategy is change, but it can be configured via _updateOn to be 'change', 'blur', or 'submit'.

Consider a scenario where the update strategy is set to blur. If the blur event fires without any user input, _pendingChange acts as a guard, preventing an unnecessary traversal of the control tree.

function setUpBlurPipeline(control: FormControl, dir: NgControl): void {
  dir.valueAccessor !.registerOnTouched(() => {
    /* ... */
    if (control.updateOn === 'blur' && control._pendingChange) updateControl(control, dir);
    /* ... */
  });
}

If the user had typed something, _pendingChange would be true, and thus the FormControl along with its parent controls would be refreshed upon the blur event.

Delving into _pendingDirty

A FormControl is deemed dirty when the user has altered its value through the interface.

function setUpViewChangePipeline(control: FormControl, dir: NgControl): void {
  dir.valueAccessor !.registerOnChange((newValue: any) => {
    /* ... */
    control._pendingChange = true;
    control._pendingDirty = true;

    if (control.updateOn === 'change') updateControl(control, dir);
  });
}

function updateControl(control: FormControl, dir: NgControl): void {
  if (control._pendingDirty) control.markAsDirty();
  /* ... */
}

The callback linked via dir.valueAccessor !.registerOnChange(cb) is triggered by the ControlValueAccessor in the view layer whenever the UI value changes.

The implementation of AbstractControl.markedAsDirty is as follows:

markAsDirty(opts: {onlySelf?: boolean} = {}): void {
  (this as{pristine: boolean}).pristine = false;

  if (this._parent && !opts.onlySelf) {
    this._parent.markAsDirty(opts);
  }
}

Therefore, when a FormControl becomes dirty due to a UI change, this state propagates upward to all its ancestors.

   FG (3)
  /  \
 FC  FG (2)
    /  \
   FC  FC (1)

(1).parent = (2)
(2).parent = (3)
(3).parent = null(root)

For example, if control (1) is a FormControl bound to an <input> and the user types, the updateControl function calls control.markAsDirty(). This action cascades from (1) up through (2) to (3), marking the entire tree as dirty.

Alternatively, you can restrict this change to only control (1) by calling (1).markedAsDirty({ onlySelf: true }).

You might ask, why not just change the dirty state immediately when the user types? The reason is the default update strategy is change, but it can be altered to blur or submit, which changes the timing.

To illustrate, here’s the sequence when a blur event occurs:

function setUpBlurPipeline(control: FormControl, dir: NgControl): void {
  dir.valueAccessor !.registerOnTouched(() => {
    /* ... */
    if (control.updateOn === 'blur' && control._pendingChange) updateControl(control, dir);
    /* ... */
  });
}

Exploring _pendingValue

This attribute holds the most current value of a FormControl.

It gets updated whenever ControlValueAccessor.onChange is called, which executes the following:

function setUpViewChangePipeline(control: FormControl, dir: NgControl): void {
  dir.valueAccessor !.registerOnChange((newValue: any) => {
    control._pendingValue = newValue;

    /* ... */

    if (control.updateOn === 'change') updateControl(control, dir);
  });
}

function updateControl(control: FormControl, dir: NgControl): void {
  if (control._pendingDirty) control.markAsDirty();
  control.setValue(control._pendingValue, {emitModelToViewChange: false});
  dir.viewToModelUpdate(control._pendingValue);
  control._pendingChange = false;
}

So, how does _pendingValue differ from value? The former represents the latest value from the view, while the latter is the value exposed to the control tree. They can diverge if the update strategy isn't set to change. The view may hold the newest data, but the model layer might not reflect it yet.

For instance, with an update strategy of submit, FormControl.value won't match _pendingValue (which mirrors the view) until the submit event is fired.

Comparing setValue() and patchValue()

// {FormGroup|FormArray}.setValue
setValue(value: {[key: string]: any}, options: {onlySelf?: boolean, emitEvent?: boolean} = {}):
    void {
  this._checkAllValuesPresent(value);
  Object.keys(value).forEach(name => {
    this._throwIfControlMissing(name);
    this.controls[name].setValue(value[name], {onlySelf: true, emitEvent: options.emitEvent});
  });
  this.updateValueAndValidity(options);
}
// {FormGroup|FormArray}.patchValue
patchValue(value: {[key: string]: any}, options: {onlySelf?: boolean, emitEvent?: boolean} = {}):
    void {
  Object.keys(value).forEach(name => {
    if (this.controls[name]) {
      this.controls[name].patchValue(value[name], {onlySelf: true, emitEvent: options.emitEvent});
    }
  });
  this.updateValueAndValidity(options);
}

Using setValue mandates that you supply a value for every control in the tree, whereas patchValue is more lenient, allowing updates to any subset of controls.

When applied to a FormGroup or FormArray, setValue first verifies that your provided object includes all existing controls, and then it checks for any superfluous controls that don't exist.

When either method is called on a FormControl, it updates the control itself followed by its ancestors. For other control types, the update order is reversed: descendants are updated first, then ancestors.

To skip updating ancestors, you can pass { onlySelf: true } as the second parameter.

Returning to our earlier example:

const fg = new FormGroup({
  name: new FormControl(''),
  address: new FormGroup({
    city: new FormControl(''),
    street: new FormControl(''),
  }),
});
   FG (4)
  /  \
 FC  FG (3) - address 
    /  \
   FC  FC
   (1) (2)

After executing the following code:

fg.get('address').setValue({ city: 'city', street: 'street' })

The process updates controls (1) and (2) first, then recalculates the value and validity of their parent (3), and finally propagates changes to any higher-level ancestors.

Example using patchValue

const c = new FormControl('');
const c2 = new FormControl('');
const a = new FormArray([c, c2]);

a.patchValue(['andrei']);
console.log(a.value) // ['andrei', '']

Example using setValue

const c1 = new FormControl('c1');
const c2 = new FormControl('c2');

const a = new FormArray([c1, c2]);

a.setValue(['c1-updated', 'c2-updated', 'c3']); // Error: Cannot find form control at index 2
a.setValue(['c1-updated']); // Error: Must supply a value for form control at index: 1

a.setValue(['c1-updated', 'c2-updated']);

console.log(a.value); // ["c1-updated", "c2-updated"]

The tree's behavior during a submit

Important: Only FormGroupDirective and NgForm are capable of invoking onSubmit.

onSubmit($event) {
  (this as{submitted: boolean}).submitted = true;
  syncPendingControls(this.form, this.directives);
  this.ngSubmit.emit($event);
  return false;
}

Some AbstractControl instances may have the updateOn option configured. If a FormControl has updateOn set to submit, its value and UI state (like dirty or untouched) are refreshed only upon the submit event. This is exactly what the syncPendingControls() method accomplishes.

// FormControl
_syncPendingControls(): boolean {
  if (this.updateOn === 'submit') {
    if (this._pendingDirty) this.markAsDirty();
    if (this._pendingTouched) this.markAsTouched();
    if (this._pendingChange) {
      this.setValue(this._pendingValue, {onlySelf: true, emitModelToViewChange: false});
      return true;
    }
  }
  return false;
}

// FormArray - FormGroup works in a very similar fashion
_syncPendingControls(): boolean {
    let subtreeUpdated = this.controls.reduce((updated: boolean, child: AbstractControl) => {
      return child._syncPendingControls() ? true : updated;
    }, false);
    if (subtreeUpdated) this.updateValueAndValidity({onlySelf: true});
    return subtreeUpdated;
  }

Let's look at an example:

this.form = this.fb.group({ name: this.fb.control('', { updateOn: 'submit' }) });

this.form.valueChanges.subscribe(console.warn);

With this template:

<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <input [formControl]="form.get('name')" type="text">
  <button type="submit">Submit</button>
</form>

you will get the same values on each submit event. Contrast that with this template:

<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <input formControlName="name" type="text">

  <br><br>
  <button type="submit">Submit</button>
</form>

you'll receive the values just once, only when the submit occurs.

This behavior stems from how FormControlName directives operate within a FormGroupDirective. The directive maintains a list of FormControlName instances via the directives property. Upon a submit event, each FormControlName resets the _pendingChange attribute of its associated FormControl to false.

directives.forEach(dir => {
  const control = dir.control as FormControl;
  if (control.updateOn === 'submit' && control._pendingChange) {
    /* ... */
    control._pendingChange = false;
  }
});

Note that FormControl._pendingChange is set to true every time a change event is triggered in the UI.

function setUpViewChangePipeline(control: FormControl, dir: NgControl): void {
  dir.valueAccessor !.registerOnChange((newValue: any) => {
    control._pendingValue = newValue;
    control._pendingChange = true;
    control._pendingDirty = true;

    if (control.updateOn === 'change') updateControl(control, dir);
  });
}

View the ng-run Example.

How to get AbstractControls from the tree

const fg = new FormGroup({
  name: new FormControl(''),
  address: new FormGroup({
    city: new FormControl(''),
    street: new FormControl(''),
  }),
});

There are several strategies for retrieving an AbstractControl.

If the target AbstractControl is a direct child of a control container (like fg below), you can access it directly:

fg.controls[nameOfCtrl];

// In our example
fg.controls['name']
fg.controls['address']

When dealing with deeply nested controls, this approach becomes cumbersome:

fg.controls['address'].controls['city']

Instead, use the AbstractControl.get() method for a cleaner solution:

fg.get('address.city')

// Or

fg.get(['address', 'street'])

Internally, get() invokes the _find function, which navigates downward through the tree along the provided path.


function _find(control: AbstractControl, path: Array<string|number>| string, delimiter: string) {
  if (path == null) return null;

  if (!(path instanceof Array)) {
    path = (<string>path).split(delimiter);
  }
  if (path instanceof Array && (path.length === 0)) return null;

  return (<Array<string|number>>path).reduce((v: AbstractControl | null, name) => {
    if (v instanceof FormGroup) {
      return v.controls.hasOwnProperty(name as string) ? v.controls[name] : null;
    }

    if (v instanceof FormArray) {
      return v.at(<number>name) || null;
    }

    return null;
  }, control);
}

As you may note, if fg were a FormArray, you could retrieve its children using an index instead of a property name (as required for FormGroup).

fg.get('1.city');

// Or

fg.get(['1', 'city']);

Understanding updateValueAndValidity()

updateValueAndValidity(opts: {onlySelf?: boolean, emitEvent?: boolean} = {}): void {
  this._setInitialStatus();
  this._updateValue();

  if (this.enabled) {
    this._cancelExistingSubscription();
    (this as{errors: ValidationErrors | null}).errors = this._runValidator(); // Sync validators
    (this as{status: string}).status = this._calculateStatus(); // VALID | INVALID | PENDING | DISABLED

    if (this.status === VALID || this.status === PENDING) {
      this._runAsyncValidator(opts.emitEvent);
    }
  }

  if (opts.emitEvent !== false) {
    (this.valueChanges as EventEmitter<any>).emit(this.value);
    (this.statusChanges as EventEmitter<string>).emit(this.status);
  }

  if (this._parent && !opts.onlySelf) {
    this._parent.updateValueAndValidity(opts);
  }
}

This method handles several critical tasks:

  1. refreshes the value of the current AbstractControl
  2. executes both synchronous and asynchronous validators
  3. determines the status based on the validator results
  4. broadcasts the new value and status to subscribers (unless emitEvent = false)
  5. repeats steps 1-4 for the parent control (unless onlySelf = true)
const fg = new FormGroup({
  name: new FormControl(''),
  address: new FormGroup({
    city: new FormControl(''),
    street: new FormControl(''),
  }),
});
   FG (3)
  /  \
 FC  FG (2)
    /  \
   FC  FC (1)

(1) - fg.get('address.street')
(2) - fg.get('address')
(3) - fg

The moment you call (1).setValue('new value'), (1).updateValueAndValidity() is triggered.

setValue(value: any, options: {
  onlySelf?: boolean,
  emitEvent?: boolean,
  emitModelToViewChange?: boolean,
  emitViewToModelChange?: boolean
} = {}): void {
  (this as{value: any}).value = this._pendingValue = value;
  if (this._onChange.length && options.emitModelToViewChange !== false) {
    this._onChange.forEach(
        (changeFn) => changeFn(this.value, options.emitViewToModelChange !== false));
  }
  this.updateValueAndValidity(options);
}

Following the update of (1), the process moves to (2), and continues upward until reaching the root control.

Enabling and disabling AbstractControls

You can disable or enable an AbstractControl from the model side. This change is reflected in the view through ControlValueAccessor.setDisabledState:

export function setUpControl(control: FormControl, dir: NgControl): void {
  /* ... */
  
  if (dir.valueAccessor !.setDisabledState) {
    control.registerOnDisabledChange(
        (isDisabled: boolean) => { dir.valueAccessor !.setDisabledState !(isDisabled); });
  }

  /* ... */
}

When disabling a control, you might want to avoid affecting its ancestors by using this.control.disable({ onlySelf: true }). This is useful when a FormControl's invalid state is making a parent FormGroup invalid.

const fg = this.fb.group({
  name: this.fb.control('', Validators.required),
  age: '',
  city: this.fb.control('', Validators.required)
});


fg.controls['name'].disable();
fg.controls['city'].disable({ onlySelf: true });

console.log(fg.valid) // false

Without { onlySelf: true }, the entire group (fg) would be marked as valid (fg.valid === true).

disable(opts: {onlySelf?: boolean, emitEvent?: boolean} = {}): void {
  // If parent has been marked artificially dirty we don't want to re-calculate the
  // parent's dirtiness based on the children.
  const skipPristineCheck = this._parentMarkedDirty(opts.onlySelf);

  (this as{status: string}).status = DISABLED;
  (this as{errors: ValidationErrors | null}).errors = null;
  this._forEachChild(
      (control: AbstractControl) => { control.disable({...opts, onlySelf: true}); });
  this._updateValue();

  if (opts.emitEvent !== false) {
    (this.valueChanges as EventEmitter<any>).emit(this.value);
    (this.statusChanges as EventEmitter<string>).emit(this.status);
  }

  // Will update the value, validity, dirtiness, and touch status
  this._updateAncestors({...opts, skipPristineCheck});
  this._onDisabledChange.forEach((changeFn) => changeFn(true));
}

private _updateAncestors(
    opts: {onlySelf?: boolean, emitEvent?: boolean, skipPristineCheck?: boolean}) {
  if (this._parent && !opts.onlySelf) {
    this._parent.updateValueAndValidity(opts);
    if (!opts.skipPristineCheck) {
      this._parent._updatePristine();
    }
    this._parent._updateTouched();
  }
}

Once an AbstractControl is disabled, its validators cease to run, its errors become null, and its child controls are also disabled.

If a parent control is artificially dirty (meaning its dirty state is not derived from its children, e.g., by manually invoking {FormGroup|FormArray}.markAsDirty), there’s no need to recalculate its dirtiness based on children since they don't contribute to it:

this.form = this.fb.group({
  name: this.fb.control({ value: 'andrei', disabled: false }),
  age: this.fb.control(''),
});

const nameCtrl = this.form.controls['name'];

// Now, its ancestors will be marked as dirty as well
// In this case, there is only one `FormGroup`(this.form)
nameCtrl.markAsDirty();

nameCtrl.disable();

// Now, `this.form` will be marked as `pristine`, because 
// the child that influenced the parent's dirtiness is disabled

Additionally, if a form-control-container (like FormGroup or FormArray) is disabled, its value is compiled from all its descendants, regardless of their individual disabled states:

const g = new FormGroup({
  name: new FormControl('name'),
  address: new FormGroup({
    city: new FormControl('city'),
    street: new FormControl('street'),
  }),
});

g.get('address.city').disable();
g.controls['name'].disable();

console.log(g.value);
/* 
{
  "address": {
    "street": "street"
  }
}
*/

g.disable();
console.log(g.value)
/* 
{
  "name": "name",
  "address": {
    "city": "city",
    "address": "address"
  }
}

The logic behind this is in how AbstractControl.disable() operates. Starting from the current control, it disables all descendants first, then gathers their values. Here’s how a FormArray would aggregate values from its children:

_updateValue(): void {
  (this as{value: any}).value =
      this.controls.filter((control) => control.enabled || this.disabled)
          .map((control) => control.value);
}

The condition control.enabled || this.disabled allows value collection even if a child control is disabled.

Conversely, if the container is not disabled, a disabled child’s value is excluded from the aggregated result.

To retrieve the form value including values from disabled controls, you can use {FormGroup|FormArray}.getRawValue():

// FormArray.getRawValue()
getRawValue(): any[] {
  return this.controls.map((control: AbstractControl) => {
    return control instanceof FormControl ? control.value : (<any>control).getRawValue();
  });
}

The mechanism behind CSS classes based on control status

CSS classes such as ng-valid, ng-pristine, or ng-touched are applied via the NgControlStatus directive, which is automatically attached when using ngModel, formControl, or formControlName.

For group-level elements (<form>, formGroupName, formGroup, ngModelGroup, formArrayName), the NgControlStatusGroup directive is applied.

Both directives refresh their applied classes in sync with the change detection cycle.

export class AbstractControlStatus {
  private _cd: AbstractControlDirective;

  constructor(cd: AbstractControlDirective) { this._cd = cd; }

  get ngClassUntouched(): boolean { return this._cd.control ? this._cd.control.untouched : false; }
  get ngClassTouched(): boolean { return this._cd.control ? this._cd.control.touched : false; }
  get ngClassPristine(): boolean { return this._cd.control ? this._cd.control.pristine : false; }
  get ngClassDirty(): boolean { return this._cd.control ? this._cd.control.dirty : false; }
  get ngClassValid(): boolean { return this._cd.control ? this._cd.control.valid : false; }
  get ngClassInvalid(): boolean { return this._cd.control ? this._cd.control.invalid : false; }
  get ngClassPending(): boolean { return this._cd.control ? this._cd.control.pending : false; }
}

export const ngControlStatusHost = {
  '[class.ng-untouched]': 'ngClassUntouched',
  '[class.ng-touched]': 'ngClassTouched',
  '[class.ng-pristine]': 'ngClassPristine',
  '[class.ng-dirty]': 'ngClassDirty',
  '[class.ng-valid]': 'ngClassValid',
  '[class.ng-invalid]': 'ngClassInvalid',
  '[class.ng-pending]': 'ngClassPending',
};

@Directive({selector: '[formControlName],[ngModel],[formControl]', host: ngControlStatusHost})
export class NgControlStatus extends AbstractControlStatus {
  constructor(@Self() cd: NgControl) { super(cd); }
}

You can create a custom directive to append your own CSS classes based on the control's validity or interaction state.

constructor (private ngControlStatus: NgControlStatus) { }

@HostBinding('[class.card__price--incorrect]') this.ngControlStatus.ngClassInvalid();

Note: For this to function, your element (or component) must also have one of these form-control-related directives: [formControlName], [ngModel], or [formControl].

Back to Table of Contents.

Wrapping Up

This deep dive should have shed light on the inner workings of Angular's forms package and underscored the flexibility it offers developers.

Thank you for sticking with this series to the end.