How does an Angular custom star-rating control validate itself through NG_VALIDATORS, and how do its errors combine with the FormControl's own validators?
answer
- the component is also a Validator
- a second multi provider
- merged, not replaced
- a callback for changed rules
basics
~20 sThe component implements Validator's validate(control), returning an errors object or null, and provides NG_VALIDATORS with useExisting, forwardRef and multi: true. The forms directive on that element merges it with the FormControl's own validators, so both run and their errors combine.
solid answer
~40 sA custom control can carry its own rule, such as "at least `minStars` stars", by also implementing `Validator`: `validate(control)` returns a `ValidationErrors` object such as `{minStars: {...}}` or `null`. It registers under a second multi-provider, `{provide: NG_VALIDATORS, useExisting: forwardRef(() => StarRating), multi: true}`, next to `NG_VALUE_ACCESSOR`. `formControlName`, `[formControl]` or `ngModel` inject `NG_VALIDATORS` from the same element and **merge** those validators with any already on the `FormControl`, so `Validators.required` from the form and the component's rule both run and their error keys end up together in `control.errors`. When a rule input changes, the component calls the function it got from `registerOnValidatorChange`, and Angular re-runs `updateValueAndValidity()`.
code
ts · 42 linesimport {Component, effect, forwardRef, input} from '@angular/core';
import {
AbstractControl, ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR,
ValidationErrors, Validator,
} from '@angular/forms';
@Component({
selector: 'app-star-rating',
template: `<!-- five star buttons, as in the plain accessor -->`,
providers: [
{provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StarRating), multi: true},
{provide: NG_VALIDATORS, useExisting: forwardRef(() => StarRating), multi: true},
],
})
export class StarRating implements ControlValueAccessor, Validator {
readonly minStars = input(1);
private onValidatorChange: () => void = () => {};
constructor() {
// re-run the control's validation whenever minStars changes
effect(() => {
this.minStars();
this.onValidatorChange();
});
}
validate(control: AbstractControl<number | null>): ValidationErrors | null {
const stars = control.value ?? 0;
return stars < this.minStars()
? {minStars: {required: this.minStars(), actual: stars}}
: null;
}
registerOnValidatorChange(fn: () => void): void {
this.onValidatorChange = fn;
}
// accessor methods stubbed here; see the plain star-rating accessor
writeValue(_: number | null): void {}
registerOnChange(_: (v: number) => void): void {}
registerOnTouched(_: () => void): void {}
}go deeper
Know that a custom control can validate itself by implementing validate() and providing NG_VALIDATORS the same way it provides NG_VALUE_ACCESSOR.
Explain that the directive merges the component's validators with the control's own, and why registerOnValidatorChange is needed when a rule input changes.
Decide which rules belong in the widget and which in the form, avoid error-key collisions, and account for updateOn timing when reading the value.
Set a library convention for which constraints components enforce themselves, so teams do not get duplicated or contradictory rules between widgets and forms.
## Why a control would validate itself Most validation belongs to the form: the page that builds `FormControl(null, Validators.required)` decides what is mandatory. Some rules, though, are intrinsic to the widget. A star-rating component configured with `minStars` knows that one star is not an acceptable answer; a colour picker that accepts a `palette` knows that a colour outside it is invalid. Re-declaring those rules in every form that uses the widget duplicates knowledge and drifts. Angular lets the component contribute a validator of its own, through the same dependency-injection mechanism it uses for the value accessor. ## The two pieces 1. **Implement `Validator`.** The interface has one required method, `validate(control: AbstractControl): ValidationErrors | null`, and one optional method, `registerOnValidatorChange(fn)`. Returning `null` means valid; returning an object means invalid, with each key naming an error. 2. **Provide `NG_VALIDATORS`.** Add `{provide: NG_VALIDATORS, useExisting: forwardRef(() => StarRating), multi: true}` to the component's `providers`, beside the `NG_VALUE_ACCESSOR` entry. For an asynchronous rule the matching token is `NG_ASYNC_VALIDATORS`, with an `AsyncValidator` whose `validate` returns a Promise or Observable. `useExisting` matters for the same reason as with the accessor: the validator must be the rendered instance, because it reads that instance's inputs. ## How the errors combine The single-control forms directives inject `NG_VALIDATORS` with the `@Self()` restriction, so only validators on **their own element** count. When the directive attaches to its `FormControl`, it does not overwrite the control's validators. It **merges** them: | Source | Example | Runs? | |---|---|---| | validators given to the `FormControl` | `Validators.required` | yes | | validator attributes on the element | `required` with `ngModel` | yes | | the component's `NG_VALIDATORS` entry | `validate()` returning `{minStars: ...}` | yes | All synchronous results are merged into one `errors` map, so a template can show `control.errors?.['required']` and `control.errors?.['minStars']` side by side. When the directive is destroyed, for example when an `@if` removes the widget, the validators it contributed are removed from the control again. ## Keeping validation current Validators run when the value changes. If the **rule** changes, say the parent binds `[minStars]="2"` and later `3`, nothing in the value changed, so Angular would keep the stale verdict. That is what `registerOnValidatorChange` is for: - the forms API calls it once, handing over a function; - the component stores the function and calls it whenever an input that affects validity changes; - the function runs `updateValueAndValidity()` on the control, so errors and status refresh. Angular's own validator directives (`minlength`, `max` and the rest) use exactly this mechanism. ## Practical rules - **Read the value from the `control` argument**, not from the component's internal display state. The control holds the committed value; with `updateOn: 'blur'` the widget may show a value the control has not received yet. - **Choose unique error keys** (`minStars`, not `min`) so they do not collide with built-in validators in the merged map; a later key with the same name overwrites the earlier one. - **Keep it cheap.** `validate` runs on every value change of that control. - **Do not duplicate `required`.** Whether a field is mandatory is a form decision; let the form add `Validators.required` and keep the component's rule to what is intrinsic to the widget. ## What the form sees From the outside the component's rule is indistinguishable from any other validator. The control's `status` becomes `INVALID`, `control.hasError('minStars')` returns `true`, the parent `FormGroup` becomes invalid too, and a submit handler that checks `form.invalid` blocks submission without knowing where the rule came from. The error object can carry detail, such as `{required: 3, actual: 2}`, which lets the page render a precise message. That also means the page and the component must agree on the error key: document it as part of the component's public contract, alongside its inputs. ## When not to use it If the rule depends on other fields (a rating is required only when a checkbox is ticked), it is a group-level concern and belongs to the form's validators, not to the widget. The component-level validator is for rules that are true wherever the widget is used.
- The parent changes [minStars] from 2 to 3 while the value stays 2, and the error does not appear. Why?Validation re-runs on value changes, not on input changes. The component must call the function it received in `registerOnValidatorChange` whenever `minStars` changes; that function runs `updateValueAndValidity()` on the control, which re-executes `validate()` against the new rule.
- Should the custom control also enforce 'required'?Usually not. Whether an answer is mandatory depends on the form, so the form should add `Validators.required` or the `required` attribute. The component's validator should carry only rules that hold wherever the widget is used, such as a minimum star count or a fixed palette.
saying these in an interview costs you the question
- NG_VALIDATORS on the component replaces the validators passed to the FormControl
- validate() should read the component's internal display value rather than control.value
- Changing a rule input automatically re-runs validation
- The validator can be provided in a parent component and still apply to the control