RFC: Strictly Typed Reactive Forms
Author: @dylhunn
Contributors: @alxhub, @AndrewKushnir
Area: Angular Framework: Forms Package
Posted: December 16, 2021
Status: Complete
Related Issue: #13721
The goal of this RFC is to validate the design with the community, solicit feedback on open questions, and enable experimentation via a non-production-ready prototype included in this proposal.
Complete: This RFC is now complete. See a summary here.
Motivation
Consider the following forms schema representing a party, which allows users to enter details about their very own party:
type Party = { address: { house: number, street: string, }, formal: boolean, foodOptions: Array<{ food: string, price: number, }> }
In the current version of Angular Forms, we can construct a corresponding form. Here’s such a form, populated with a default value. This default party is happening at 1234 Powell St, is not a formal event, and has no food options:
const partyForm = new FormGroup({ address: new FormGroup({house: new FormControl(1234), street: new FormControl('Powell St')}), formal: new FormControl(false), foodOptions: new FormArray([]) });
Now let's try to interact with our form. As you can see, we frequently get values of type any when reading it. The type any is far too permissive, and is very unsafe. This issue is pervasive across the entire Forms API:
const partyDetails = partyForm.getRawValue(); // type `any` const where = partyForm.get('address.street')!.value; // type `any` partyForm.controls.formal.setValue(true); // param has type `any`
However, with typed forms, the types are highly specific and far more helpful:
const partyDetails = partyForm.getRawValue(); // a `Party` object const where = partyForm.get('address.street')!.value; // type `string` partyForm.controls.formal.setValue(true); // param has type `boolean`
These are much more useful types, and consumers that handle them incorrectly will get a compiler error (instead of a silent bug). For example, trying to do arithmetic on a value of a string control will now be an error: partyForm.get('address.street')!.value + 6.
This illustrates the purpose of typed forms: the API now reflects the structure of the form and its data. These benefits should prove especially useful for very complex or deeply nested forms.
Goals and Non-Goals
Goals
- Improve the developer experience for Angular Reactive Forms.
- Avoid fragmenting the ecosystem around forms by maintaining a single version of the Forms package.
- Provide as much type-safety as possible, balancing against API complexity.
- Support gradual typing, allowing typed and untyped forms to be mixed.
- Ability to land the changes without breaking existing applications.
Non-Goals
- We don't intend to change template-driven forms. (see section on limitations below for more details)
- We also are not targeting non-model classes right now, such as Validator.
- We will not change the runtime behavior of the Forms package -- everything should work the same as today.
This is not a redesign of Forms; improvements are narrowly focused on incrementally adding types to the existing system.
Tour of the Typed API
Backwards-Compatibility
Let’s use our new API to create a FormGroup. As you can see, the existing API has been extended in a backwards-compatible way: this code snippet will work with or without typed forms.
const cat = new FormGroup({ name: new FormControl('bob'), lives: new FormControl(9), });
Once the typed forms API is rolled out, interacting with this cat form will be much safer than before:
const name = cat.value.name; // type `string|null` cat.controls.name.setValue(42); // Error! `name` has type `string|null`
Existing projects may not be 100% compatible with this stricter version of the reactive forms API at launch. To avoid build breakage, ng update will migrate existing calls to opt out of typed forms by providing an explicit any when constructing forms objects, thus aligning them with the current untyped semantics:
const cat = new FormGroup<any>({ name: new FormControl<any>('bob'), lives: new FormControl<any>(9), });
This <any> causes form APIs to function with the same semantics as untyped forms do today, allowing for an incremental migration path where applications and libraries can gradually improve type safety without fixing every type error at once.
In practice, we will add a type alias for any (e.g. AnyForUntypedForms) to attach some documentation to this particular usage and allow it to be easily recognized in application code.
Nullable Controls and Reset
Careful observers may note that null is showing up in the FormControl types above. This is because form models can be .reset() at any time, and the value of a reset() control is by default null:
const dog = new FormControl('spot'); // dog has type FormControl<string|null> dog.reset(); const whichDog = dog.value; // null
This behavior is built into the forms runtime, and so the typed forms API infers nullable controls by default. However, this can make value types more inconvenient to work with. To improve the ergonomics, we're adding the ability for FormControls to be reset to a default value instead of null:
const dog = new FormControl('spot', {initialValueIsDefault: true}); // dog has type FormControl<string> dog.reset(); const whichDog = dog.value; // spot
This gives you a choice – we’ll provide as much type safety as possible for old uses of FormControl, or you can provide a default value to get null-safety as well.
FormGroup Types
A FormGroup infers a type based on its inner controls. Recall our cat type from above:
const cat = new FormGroup<{ name: FormControl<string>, lives: FormControl<number>, }>(...);
In other words, a FormGroup's generic type is an interface that describes the types of each of its inner controls.
This may seem surprising, as one might imagine this type should describe the values instead:
interface Cat { name: string; lives: number; } const cat = new FormGroup<Cat>({ name: new FormControl('spot, …),, lives: new FormControl(9, …), });
However, we want to strongly type not just FormGroup.value, but FormGroup.controls. That is, the type of cat.controls.name should be the actual type of the name control, and not a plain AbstractControl type. This is only possible if the type of cat is built on the control types that it contains, not the value types of those controls.
Disabled Controls
The value property of a FormGroup is an object that contains the values of each constituent control, with one important difference: the value key for every control is optional. That is, the type of cat.value in the example above looks like the interface:
interface CatValue { name?: string; lives?: number; }
This may seem surprising - any given key on the value object may not be present (and thus undefined if read). This happens because of the way disabled controls work in a FormGroup. When a control in a group is disabled, its value is not included in the value object:
// Disabling the `lives` key removes it from the group's value! cat.controls.lives.disable(); console.log(cat.value.lives); // prints 'undefined'
If you want a value object for the group that includes the values for disabled controls, use the .rawValue() method instead.
The get Method
AbstractControl provides a get method for accessing descendant controls by name:
const g = new FormGroup({ 'a': new FormControl('foo'), 'b': new FormGroup({'c': new FormControl('bar')}) }); const val = g.get('b.c')!.value; // 'bar', has type string|null
We have implemented strong types for this method using template literal types. As long as a constant string literal is provided as the argument, we will tokenize it and extract the type of the requested control. If the argument is not a literal (e.g. it’s a string variable), the return type will be any.
Adding and Removing Controls
FormGroup provides methods to dynamically modify its keys, such as removeControl. In this proposal, such a call will only be allowed if the key is explicitly marked optional:
interface CatGroup { name: FormControl<string|null>, lives?: FormControl<number|null>, } const cat = new FormGroup<CatGroup>({ name: new FormControl('bob'), lives: new FormControl(9), }); cat.removeControl('lives');
In this example, lives can be removed because the CatGroup interface which describes the FormGroup specifies it as an optional property. If the ? was not present in the type, then the lives key would not be removable.
Some applications use FormGroup as an open-ended dictionary, where the set of controls is not known at build time. For these cases, untyped forms can be used via FormGroup<any>.
An alternative would be to introduce a new class, FormRecord, in which keys can be dynamically added and removed. The type guarantees for FormRecord would be much weaker than with immutable FormGroup.
FormBuilder
In addition to typing the model classes, we have also added types to FormBuilder. Each builder method takes a type parameter, which will typically be inferred. That parameter works in the same manner as if the control had been constructed directly.
There are a number of ways to provide values to FormBuilder. All of these methods have been strongly typed:
const b = new FormBuilder(); const a = b.array([ // A raw value 'one', // A ControlConfig tuple ['two', someSyncValidator, someAsyncValidator], // A boxed FormState {value: 'three', disabled: false}, // A control b.control('four'), ]); // ['one', 'two', 'three', 'four'] const counting = a.value; // string[]
As you can see, you can provide a raw value, a control, or a boxed value, and the type will be inferred.
Usages of FormBuilder will have <any> or <any[]> inserted on pre-existing method calls, to preserve backwards compatibility.
Limitations
Control Bindings
When a FormControl is bound in a template, Angular's template type checking engine will not be able to assert that the value produced by the underlying control (described by its ControlValueAccessor) is of the same type as the FormControl. That is, the following:
const name = new FormControl('name'); // inferred type is FormControl<string|null> <!-- error: string-valued FormControl bound to numeric-valued DOM control --> <input type="range" [formControl]="name">
will result in name.value returning numeric values from the <input type="range">, despite being typed as FormControl<string|null>.
This is a limitation of the current template type-checking mechanism, due to the fact that the FormControlDirective which binds the control does not have access to the type of the ControlValueAccessor which describes the DOM control - each directive type is independent of any other directives on a given element. We have a few ideas on how to remove this restriction, but feel there is significant value in delivering stronger typings for forms even without this checking in place.
Template Driven Forms
The above restriction also applies to NgModel and template driven forms, which is why we've focused our efforts on reactive forms alone.
Because reactive form models are created in TypeScript code, there's a natural syntax for explicitly declaring their types if necessary. No such syntax exists in Angular's template language, further complicating any potential typings for template driven forms.
Try the Prototype
There is a prototype PR with an implementation. Below, we provide two methods for trying it out. This is a draft implementation, with missing features and non-final design aspects.
To try it on StackBlitz:
- Go to the demo StackBlitz project.
- Wait for all dependencies to be fetched and installed.
- Run
ng serve. - Edit
profile.component.tsto use the new typings.
To try it with a demo app locally:
- Download the demo app:
git clone https://github.com/dylhunn/typed-forms-example-app.git && cd typed-forms-example-app - Install all dependencies with
npm i --force -g yarn && yarn. As illustrated, you may need to force install them due to the experimental package versions. - Run the app:
ng serve --open - Try out the new types by editing
src/app/profile/profile.component.ts
To try it with your app:
- Ensure your app is on Angular
13.x.x, upgrading if necessary - Create a new branch:
git checkout -b typed-forms-experiment - Delete your node_modules folder:
rm -rf node_modules - Reinstall all dependencies:
npm ioryarn - Update your app to the experimental
nextrelease:ng update @angular/core --next. Yourpackage.jsonshould now show that all angular packages are using the13.2.0-next.2version or higher. - Open
package.jsonin your project’s root directory. Find@angular/forms, and replace~13.2.0-next.xwithhttps://1106843-24195339-gh.circle-artifacts.com/0/angular/forms-pr43834-8e5ba4f698.tgz. - Install the new dependencies (
npm ioryarn, depending on which package manager you are using). You will see peer dependency warnings because the experimental forms package has a prerelease version number; these should be ignored and/or overridden by force. - Make a new commit:
git add . && git commit -m "upgraded to experimental angular package versions" - Run the migration:
ng update @angular/core --migrate-only migration-v14-typed-forms. - Your app should now build, and
anys should have been inserted at all forms call sites. You can remove theseanys to use the new types.
Questions for Discussion
In addition to general feedback, we would like to collect feedback on the following specific questions:
1. Is there a compelling use case for tuple-typed FormArrays?
In the current design, FormArrays are homogeneous - every control in a FormArray is of the same type. However, TypeScript itself supports arrays where each element has its own type - known as a tuple type.
For example, the type [string, number] describes an array which must have at least 2 elements, where the first element is a string and the second is a number.
Our proposed design for FormArray does not support such cases (instead, FormArray<any> could be used, falling back to untyped semantics).
We are interested in any cases where a tuple-typed compound control would provide value.
2. Is there a compelling use case for a map style FormGroup?
In the current design, a typed FormGroup requires that all possible control keys are known statically. In some applications, FormGroups are used as maps, with a set of controls with dynamic keys that are added at runtime. For these cases, we currently recommend falling back to untyped form semantics using FormGroup<any>.
An alternative would be to provide an explicit FormGroup analogue that supports a dynamic mapping of controls. The tradeoff would likely be that all controls present in the grouping would have the same value type. Essentially, it would behave as the forms version of a Map<string, T>.
We would be interested in whether this kind of compound control ("FormRecord") would significantly improve the ergonomics of use cases where FormGroup is currently used to manage a dynamic set of controls.
3. Is the current nullability of FormControls useful?
The original forms API allowed for initialization at construction to a specific value. However, controls would always use null as a default value to which they would revert when reset() - this means that all controls would necessarily be nullable.
For typed forms, we are introducing a configuration option to use the initial value as the default value instead, allowing for non-nullable controls.
Our long term plan is to remove the null reset behavior entirely, and always use the initial value as the default/reset value. To do this, in the future we will make initialValueIsDefault: true the default behavior, and eventually deprecate and remove the flag entirely.
For those cases where a truly independent initial value is required, the value can be changed via setValue immediately following the control's construction.
We would be interested in any use cases where this change in default value behavior would be problematic or burdensome, and where the current reset-to-null behavior is important.
4. Are the limitations involving control bindings a blocking issue?
As discussed above, binding to controls via directives (such as formControl and formControlName) is not type-safe. This can be improved in a future release, by adding warnings when a control is bound with an incompatible type. Is this shortcoming severe enough that we should delay any typings until it can be solved?
5. Does the prototype migration correctly handle existing projects?
The prototype shown above includes a migration to add <any> to existing forms usages. We would be especially interested if any cases are discovered where this migration does not apply correctly or does not insulate existing code from the effects of adding types to forms.