Description
Which @angular/* package(s) are relevant/related to the feature request?
forms
Description
Template and reactive forms in Angular are great, yet using them through the years has revealed a few areas of improvement. This issue aims to provide feedback on some problems, hoping they can be addressed in the future re-vamped signal forms. I'd be happy to elaborate and provide further examples of any of the points below.
Problem 1: Control Value Accessor is not strongly typed
CVA provide a nice separation of concerns and layer of abstraction between the form control and the DOM.
Nevertheless, form controls, either the reactive or template flavors, access the CVA through DI. This means that the binding is not strictly typed. A CVA can be designed to handle numbers, yet be used in a model that has the string value.
Additionally, the CVA interface builds on top of any
, which offers a bad DX.
Example:
class NumberValueAccessor implements ControlValueAccessor {
writeValue(value: number): void { /* ... */ }
registerOnChange(fn: (value: number) => void): void { /* ... */ }
// ...
}
// Usage in a form control that expects a string
this.form = new FormGroup({
age: new FormControl('25') // This should be a number
});
Problem 2: Control Value Accessor can cause issues with the timing of its binding
The timing when writeValue
and registerOnChange
are called is somehow part of its public API, following Hyrum's Law. It can cause some issues, for example, if the CVA supports multiple types and does a parsing on writeValue
. The first time it get's called, registerOnChange
hasn't been called yet, so the parsed value can't be immediately propagated.
Additionally, this API feels verbose and not declarative when compared with more recent framework features.
Example:
class MultiTypeValueAccessor implements ControlValueAccessor {
writeValue(value: string | number): void {
const parsedValue = typeof value === 'string' ? parseInt(value, 10) : value;
// registerOnChange hasn't been called yet, so parsedValue can't be propagated
}
registerOnChange(fn: (value: number) => void): void { /* ... */ }
// ...
}
Problem 3: Strict typing of form controls and groups
When typed forms were introduced, they had to be retro-compatible with existing implementations, which we appreciated. Nevertheless, it is painful to work with the undefined
type of controls just because they can be disabled. I'd argue that this decision has caused typing issues in many cases, since the form value when is valid still has undefined
all around. True, the raw value can be used, but feels unnecessary.
Additionally, there is a common case where a field uses the required validator, but it's underlying type also supports null
. If the form is valid, the control would have a non-null value, yet the type still includes null
.
Example:
const form = new FormGroup({
name: new FormControl<string | null>(null, Validators.required)
});
// When the form is valid, name should not be null, but the type still includes null
const name: string | null = form.get('name')?.value;
Finally, there are some controls that do parsing, such as supporting both Date | string
as inputs, but always exposing a Date
. Again, it can't be expressed with current typing, there is no transformation function or type narrowing.
Example:
const dateControl = new FormControl<Date | string>('2025-01-01');
// The control should always expose a Date, but it can accept a string
const date: Date = dateControl.value; // This can't be done
Problem 4: Error fields does not have strict typing
Regardless of the return type of a validator, the errors
property of controls is typed as any
. For example, the max length validator exposes an error value similar to {requiredLength: 5, actualLength: 7}
, but it can be consumed with type safety in the template.
Example:
const control = new FormControl('test', Validators.maxLength(5));
const errors = control.errors; // Typed as any, not safe to access properties
if (errors?.maxlength) {
console.log(errors.maxlength.requiredLength); // No type safety
}
Problem 5: Accessibility with reactive form validators
Reactive form validators provide a great tooling to build dynamic validators. However, they don't add HTML attributes such as required
or maxlength
, lacking a11n support. Only template driven validators do this. For those who embrace the virtues of reactive forms, validators have to be duplicated in the control and template to ensure good HTML attributes.
Example:
const control = new FormControl('', [Validators.required, Validators.maxLength(5)]);
// HTML does not reflect required or maxlength attributes
Problem 6: Async validators are still pending when the form is submitted
One of the most common scenarios when using forms is acting when they are submitted. Currently, when the submit event is fired, a form with async validators is in pending state. It is not trivial to wait until they complete so as to decide whether the form action should be executed.
Example:
this.formGroup = new FormGroup({
asyncControl: new FormControl('', null, asyncValidator)
});
onSubmit() {
if (this.formGroup.pending) {
// Form is still pending, can't proceed with submission
console.log('Form is pending, please wait...');
} else if (this.formGroup.valid) {
// Form is valid, proceed with submission
console.log('Form submitted successfully:', this.formGroup.value);
} else {
// Form is invalid, handle errors
console.log('Form is invalid:', this.formGroup.errors);
}
}
Problem 7: Complex typing structures
Typed form's typing is complex. For example, a form group can't be typed with just the type of its value, it needs to know whether each field is a control, group, or array.
Proposed solution
Proposed solution 1: Bind CVA through inputs to ensure strict typing
Ideally, a CVA could expose a model
consumed through two-way data binding rather than through DI. Since inputs are the only binding that enforces type safety, it would ensure that the CVA and its model value have the same type.
Proposal:
export interface ControlValueAccessor<T> {
readonly model: `ModelSignal<T>`;
}
Proposed solution 2: Use model
for binding of the CVA
Having two methods for the data binding of a CVA could be better replaced with the new model
that solves this specific problem and avoids timing issues.
Proposed solution 3: Support type narrowing
Reconsider the decision that disabled controls should have undefined
value and avoid the need of exposing value
vs rawValue
. If a control was undefined, it would be an application decision whether the value should be undefined
. Another alternative to solve the typing issue would be only support disable on controls that include the type undefined
.
If type narrowing could be applied to form controls, we could benefit, for example, from:
- Required validator would ensure that, if successful, the value is not null and reflect that in the type.
- A control that does parsing would support multiple values when "setting", but a narrowed type when "getting".
Proposal:
export function RequiredValidator<T>(control: AbstractControl<T | undefined>): control is AbstractControl<T>;
Proposed solution 4: Support generic type for errors
If a control is defined with validators, make them part of the generic type so that errors
has a strict type.
Proposed solution 5: Enhance API of programmatic validators
A programmatic validator would have an API similar to host
so that it can modify the HTML and add a11y attributes as needed.
Proposed solution 6: Provide success
and error
events on forms
Rather than relying on submit, provide success
(or validSubmit
) or error
(or invalidSubmit
) events that get triggered when, after the form is submitted, async validators are resolved, and the state is no longer pending, depending on whether it is valid
or invalid
.
The success event could also expose a narrowed type with the final types when validators are successful, following up on proposed solution 3.
Proposed solution 7: Simplify typing for form groups/arrays
A form group/array would only have child abstract controls, regardless of their specific type. This way they can be typed with the value's type. If it had a child object-shaped field, it could later be bound to a control with an object value, or a child group dynamically.
Alternatives considered
Creating my own forms library, but I really like Angular's 😉