Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
feat(forms): Implement strict types for the Angular Forms package. (#…
…43834)

This PR strongly types the forms package by adding generics to AbstractControl classes as well as FormBuilder. This makes forms type-safe and null-safe, for both controls and values.

The design uses a "control-types" approach. In other words, the type parameter on FormGroup is an object containing controls, and the type parameter on FormArray is an array of controls.

Special thanks to Alex Rickabaugh and Andrew Kushnir for co-design & implementation, to Sonu Kapoor and Netanel Basal for illustrative prior art, and to Cédric Exbrayat for extensive testing and validation.

BREAKING CHANGE: Forms classes accept a generic.

Forms model classes now accept a generic type parameter. Untyped versions of these classes are available to opt-out of the new, stricter behavior.

PR Close #43834
  • Loading branch information
dylhunn authored and jessicajaniuk committed Apr 12, 2022
1 parent d11d1c0 commit 89d2991
Show file tree
Hide file tree
Showing 18 changed files with 2,059 additions and 314 deletions.
23 changes: 13 additions & 10 deletions aio/content/examples/ngmodules/src/app/contact/contact.component.ts
@@ -1,14 +1,15 @@
// Exact copy except import UserService from greeting
import { Component, OnInit } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import {Component, OnInit} from '@angular/core';
import {FormBuilder, FormGroup, Validators} from '@angular/forms';

import { Contact, ContactService } from './contact.service';
import { UserService } from '../greeting/user.service';
import {UserService} from '../greeting/user.service';

import {Contact, ContactService} from './contact.service';

@Component({
selector: 'app-contact',
templateUrl: './contact.component.html',
styleUrls: [ './contact.component.css' ]
styleUrls: ['./contact.component.css']
})
export class ContactComponent implements OnInit {
contact!: Contact;
Expand All @@ -17,11 +18,11 @@ export class ContactComponent implements OnInit {
msg = 'Loading contacts ...';
userName = '';

contactForm = this.fb.group({
name: ['', Validators.required]
});
contactForm: FormGroup;

constructor(private contactService: ContactService, userService: UserService, private fb: FormBuilder) {
constructor(
private contactService: ContactService, userService: UserService, private fb: FormBuilder) {
this.contactForm = this.fb.group({name: ['', Validators.required]});
this.userName = userService.userName;
}

Expand All @@ -40,7 +41,9 @@ export class ContactComponent implements OnInit {

next() {
let ix = 1 + this.contacts.indexOf(this.contact);
if (ix >= this.contacts.length) { ix = 0; }
if (ix >= this.contacts.length) {
ix = 0;
}
this.contact = this.contacts[ix];
console.log(this.contacts[ix]);
}
Expand Down
159 changes: 105 additions & 54 deletions goldens/public-api/forms/index.md
Expand Up @@ -21,7 +21,7 @@ import { SimpleChanges } from '@angular/core';
import { Version } from '@angular/core';

// @public
export abstract class AbstractControl {
export abstract class AbstractControl<TValue = any, TRawValue extends TValue = TValue> {
constructor(validators: ValidatorFn | ValidatorFn[] | null, asyncValidators: AsyncValidatorFn | AsyncValidatorFn[] | null);
addAsyncValidators(validators: AsyncValidatorFn | AsyncValidatorFn[]): void;
addValidators(validators: ValidatorFn | ValidatorFn[]): void;
Expand All @@ -41,7 +41,8 @@ export abstract class AbstractControl {
}): void;
get enabled(): boolean;
readonly errors: ValidationErrors | null;
get(path: Array<string | number> | string): AbstractControl | null;
get<P extends string | (readonly (string | number)[])>(path: P): AbstractControlGetProperty<TRawValue, P>> | null;
get<P extends string | Array<string | number>>(path: P): AbstractControlGetProperty<TRawValue, P>> | null;
getError(errorCode: string, path?: Array<string | number> | string): any;
getRawValue(): any;
hasAsyncValidator(validator: AsyncValidatorFn): boolean;
Expand All @@ -66,21 +67,20 @@ export abstract class AbstractControl {
onlySelf?: boolean;
}): void;
get parent(): FormGroup | FormArray | null;
abstract patchValue(value: any, options?: Object): void;
abstract patchValue(value: TValue, options?: Object): void;
get pending(): boolean;
readonly pristine: boolean;
removeAsyncValidators(validators: AsyncValidatorFn | AsyncValidatorFn[]): void;
removeValidators(validators: ValidatorFn | ValidatorFn[]): void;
abstract reset(value?: any, options?: Object): void;
abstract reset(value?: TValue, options?: Object): void;
get root(): AbstractControl;
setAsyncValidators(validators: AsyncValidatorFn | AsyncValidatorFn[] | null): void;
setErrors(errors: ValidationErrors | null, opts?: {
emitEvent?: boolean;
}): void;
// (undocumented)
setParent(parent: FormGroup | FormArray): void;
setParent(parent: FormGroup | FormArray | null): void;
setValidators(validators: ValidatorFn | ValidatorFn[] | null): void;
abstract setValue(value: any, options?: Object): void;
abstract setValue(value: TRawValue, options?: Object): void;
readonly status: FormControlStatus;
readonly statusChanges: Observable<FormControlStatus>;
readonly touched: boolean;
Expand All @@ -93,8 +93,8 @@ export abstract class AbstractControl {
get valid(): boolean;
get validator(): ValidatorFn | null;
set validator(validatorFn: ValidatorFn | null);
readonly value: any;
readonly valueChanges: Observable<any>;
readonly value: TValue;
readonly valueChanges: Observable<TValue>;
}

// @public
Expand Down Expand Up @@ -223,37 +223,37 @@ export interface Form {
}

// @public
export class FormArray extends AbstractControl {
constructor(controls: AbstractControl[], validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null);
at(index: number): AbstractControl;
export class FormArray<TControl extends AbstractControl<any> = any> extends AbstractControlTypedOrUntyped<TControl, ɵFormArrayValue<TControl>, any>, ɵTypedOrUntyped<TControl, ɵFormArrayRawValue<TControl>, any>> {
constructor(controls: Array<TControl>, validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null);
at(index: number): ɵTypedOrUntyped<TControl, TControl, AbstractControl<any>>;
clear(options?: {
emitEvent?: boolean;
}): void;
// (undocumented)
controls: AbstractControl[];
getRawValue(): any[];
insert(index: number, control: AbstractControl, options?: {
controls: ɵTypedOrUntyped<TControl, Array<TControl>, Array<AbstractControl<any>>>;
getRawValue(): ɵFormArrayRawValue<TControl>;
insert(index: number, control: TControl, options?: {
emitEvent?: boolean;
}): void;
get length(): number;
patchValue(value: any[], options?: {
patchValue(value: ɵFormArrayValue<TControl>, options?: {
onlySelf?: boolean;
emitEvent?: boolean;
}): void;
push(control: AbstractControl, options?: {
push(control: TControl, options?: {
emitEvent?: boolean;
}): void;
removeAt(index: number, options?: {
emitEvent?: boolean;
}): void;
reset(value?: any, options?: {
reset(value?: ɵTypedOrUntyped<TControl, ɵFormArrayValue<TControl>, any>, options?: {
onlySelf?: boolean;
emitEvent?: boolean;
}): void;
setControl(index: number, control: AbstractControl, options?: {
setControl(index: number, control: TControl, options?: {
emitEvent?: boolean;
}): void;
setValue(value: any[], options?: {
setValue(value: ɵFormArrayRawValue<TControl>, options?: {
onlySelf?: boolean;
emitEvent?: boolean;
}): void;
Expand All @@ -276,13 +276,32 @@ export class FormArrayName extends ControlContainer implements OnInit, OnDestroy

// @public
export class FormBuilder {
array(controlsConfig: any[], validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null): FormArray;
control(formState: any, validatorOrOpts?: ValidatorFn | ValidatorFn[] | FormControlOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null): FormControl;
group(controlsConfig: {
[key: string]: any;
}, options?: AbstractControlOptions | null): FormGroup;
// (undocumented)
array<T>(controls: Array<FormControl<T>>, validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null): FormArray<FormControl<T>>;
// (undocumented)
array<T extends {
[K in keyof T]: AbstractControl<any>;
}>(controls: Array<FormGroup<T>>, validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null): FormArray<FormGroup<T>>;
// (undocumented)
array<T extends AbstractControl<any>>(controls: Array<FormArray<T>>, validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null): FormArray<FormArray<T>>;
// (undocumented)
array<T extends AbstractControl<any>>(controls: Array<AbstractControl<T>>, validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null): FormArray<AbstractControl<T>>;
// (undocumented)
array<T>(controls: Array<FormControlState<T> | ControlConfig<T> | T>, validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null): FormArray<FormControl<T | null>>;
// (undocumented)
control<T>(formState: T | FormControlState<T>, opts: FormControlOptions & {
initialValueIsDefault: true;
}): FormControl<T>;
// (undocumented)
control<T>(formState: T | FormControlState<T>, validatorOrOpts?: ValidatorFn | ValidatorFn[] | FormControlOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null): FormControl<T | null>;
// (undocumented)
group<T extends {
[K in keyof T]: FormControlState<any> | ControlConfig<any> | FormControl<any> | FormGroup<any> | FormArray<any> | AbstractControl<any> | T[K];
}>(controls: T, options?: AbstractControlOptions | null): FormGroup<{
[K in keyof T]: ɵGroupElement<T[K]>;
}>;
// @deprecated
group(controlsConfig: {
group(controls: {
[key: string]: any;
}, options: {
[key: string]: any;
Expand All @@ -294,21 +313,22 @@ export class FormBuilder {
}

// @public
export interface FormControl extends AbstractControl {
readonly defaultValue: any;
patchValue(value: any, options?: {
export interface FormControl<TValue = any> extends AbstractControl<TValue> {
readonly defaultValue: TValue;
getRawValue(): TValue;
patchValue(value: TValue, options?: {
onlySelf?: boolean;
emitEvent?: boolean;
emitModelToViewChange?: boolean;
emitViewToModelChange?: boolean;
}): void;
registerOnChange(fn: Function): void;
registerOnDisabledChange(fn: (isDisabled: boolean) => void): void;
reset(formState?: any, options?: {
reset(formState?: TValue | FormControlState<TValue>, options?: {
onlySelf?: boolean;
emitEvent?: boolean;
}): void;
setValue(value: any, options?: {
setValue(value: TValue, options?: {
onlySelf?: boolean;
emitEvent?: boolean;
emitModelToViewChange?: boolean;
Expand Down Expand Up @@ -370,43 +390,74 @@ export interface FormControlOptions extends AbstractControlOptions {
initialValueIsDefault?: boolean;
}

// @public
export interface FormControlState<T> {
// (undocumented)
disabled: boolean;
// (undocumented)
value: T;
}

// @public
export type FormControlStatus = 'VALID' | 'INVALID' | 'PENDING' | 'DISABLED';

// @public
export class FormGroup extends AbstractControl {
constructor(controls: {
[key: string]: AbstractControl;
}, validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null);
addControl(name: string, control: AbstractControl, options?: {
export class FormGroup<TControl extends {
[K in keyof TControl]: AbstractControl<any>;
} = any> extends AbstractControlTypedOrUntyped<TControl, ɵFormGroupValue<TControl>, any>, ɵTypedOrUntyped<TControl, ɵFormGroupRawValue<TControl>, any>> {
constructor(controls: TControl, validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null);
addControl(this: FormGroup<{
[key: string]: AbstractControl<any>;
}>, name: string, control: AbstractControl, options?: {
emitEvent?: boolean;
}): void;
contains(controlName: string): boolean;
// (undocumented)
controls: {
[key: string]: AbstractControl;
};
getRawValue(): any;
patchValue(value: {
[key: string]: any;
}, options?: {
addControl<K extends string & keyof TControl>(name: K, control: Required<TControl>[K], options?: {
emitEvent?: boolean;
}): void;
contains<K extends string>(controlName: K): boolean;
// (undocumented)
contains(this: FormGroup<{
[key: string]: AbstractControl<any>;
}>, controlName: string): boolean;
// (undocumented)
controls: ɵTypedOrUntyped<TControl, TControl, {
[key: string]: AbstractControl<any>;
}>;
getRawValue(): ɵTypedOrUntyped<TControl, ɵFormGroupRawValue<TControl>, any>;
patchValue(value: ɵFormGroupValue<TControl>, options?: {
onlySelf?: boolean;
emitEvent?: boolean;
}): void;
registerControl(name: string, control: AbstractControl): AbstractControl;
removeControl(name: string, options?: {
registerControl<K extends string & keyof TControl>(name: K, control: TControl[K]): TControl[K];
// (undocumented)
registerControl(this: FormGroup<{
[key: string]: AbstractControl<any>;
}>, name: string, control: AbstractControl<any>): AbstractControl<any>;
// (undocumented)
removeControl(this: FormGroup<{
[key: string]: AbstractControl<any>;
}>, name: string, options?: {
emitEvent?: boolean;
}): void;
// (undocumented)
removeControl<S extends string>(name: ɵOptionalKeys<TControl> & S, options?: {
emitEvent?: boolean;
}): void;
reset(value?: any, options?: {
reset(value?: ɵTypedOrUntyped<TControl, ɵFormGroupValue<TControl>, any>, options?: {
onlySelf?: boolean;
emitEvent?: boolean;
}): void;
setControl(name: string, control: AbstractControl, options?: {
setControl<K extends string & keyof TControl>(name: K, control: TControl[K], options?: {
emitEvent?: boolean;
}): void;
setValue(value: {
[key: string]: any;
}, options?: {
// (undocumented)
setControl(this: FormGroup<{
[key: string]: AbstractControl<any>;
}>, name: string, control: AbstractControl, options?: {
emitEvent?: boolean;
}): void;
setValue(value: ɵFormGroupRawValue<TControl>, options?: {
onlySelf?: boolean;
emitEvent?: boolean;
}): void;
Expand Down Expand Up @@ -724,7 +775,7 @@ export class SelectMultipleControlValueAccessor extends BuiltInControlValueAcces
}

// @public
export type UntypedFormArray = FormArray;
export type UntypedFormArray = FormArray<any>;

// @public (undocumented)
export const UntypedFormArray: UntypedFormArrayCtor;
Expand Down Expand Up @@ -752,13 +803,13 @@ export class UntypedFormBuilder extends FormBuilder {
}

// @public
export type UntypedFormControl = FormControl;
export type UntypedFormControl = FormControl<any>;

// @public (undocumented)
export const UntypedFormControl: UntypedFormControlCtor;

// @public
export type UntypedFormGroup = FormGroup;
export type UntypedFormGroup = FormGroup<any>;

// @public (undocumented)
export const UntypedFormGroup: UntypedFormGroupCtor;
Expand Down
6 changes: 3 additions & 3 deletions modules/playground/src/model_driven_forms/index.ts
Expand Up @@ -8,7 +8,7 @@

/* tslint:disable:no-console */
import {Component, Host, NgModule} from '@angular/core';
import {AbstractControl, FormBuilder, FormGroup, FormGroupDirective, ReactiveFormsModule, Validators} from '@angular/forms';
import {AbstractControl, FormBuilder, FormGroup, FormGroupDirective, ReactiveFormsModule, UntypedFormBuilder, UntypedFormGroup, Validators} from '@angular/forms';
import {BrowserModule} from '@angular/platform-browser';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';

Expand Down Expand Up @@ -139,10 +139,10 @@ export class ShowError {
`
})
export class ReactiveForms {
form: FormGroup;
form: UntypedFormGroup;
countries = ['US', 'Canada'];

constructor(fb: FormBuilder) {
constructor(fb: UntypedFormBuilder) {
this.form = fb.group({
'firstName': ['', Validators.required],
'middleName': [''],
Expand Down
4 changes: 2 additions & 2 deletions packages/core/schematics/migrations.json
Expand Up @@ -6,8 +6,8 @@
"factory": "./migrations/entry-components/index"
},
"migration-v14-typed-forms": {
"version": "9999.0.0",
"description": "Experimental migration that adds <any>s for Typed Forms.",
"version": "14.0.0-beta",
"description": "As of Angular version 14, Forms model classes accept a type parameter, and existing usages must be opted out to preserve backwards-compatibility.",
"factory": "./migrations/typed-forms/index"
},
"migration-v14-path-match-type": {
Expand Down
Expand Up @@ -1095,7 +1095,7 @@
"name": "isEmptyInputValue"
},
{
"name": "isFormControl"
"name": "isFormControlState"
},
{
"name": "isForwardRef"
Expand Down Expand Up @@ -1353,7 +1353,7 @@
"name": "removeFromArray"
},
{
"name": "removeListItem"
"name": "removeListItem2"
},
{
"name": "removeStyle"
Expand Down

0 comments on commit 89d2991

Please sign in to comment.