Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

[Complete] RFC: Strictly Typed Reactive Forms#44513

Locked
dylhunn announced inRFCs
Dec 16, 2021· 34 comments· 80 replies
Discussion options

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:

typeParty={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 at1234 Powell St, is not a formal event, and has no food options:

constpartyForm=newFormGroup({address:newFormGroup({house:newFormControl(1234),street:newFormControl('Powell St')}),formal:newFormControl(false),foodOptions:newFormArray([])});

Now let's try to interact with our form. As you can see, we frequently get values of typeany when reading it. The typeany is far too permissive, and is very unsafe. This issue is pervasive across the entire Forms API:

constpartyDetails=partyForm.getRawValue();// type `any`constwhere=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:

constpartyDetails=partyForm.getRawValue();// a `Party` objectconstwhere=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

  1. Improve the developer experience for Angular Reactive Forms.
  2. Avoid fragmenting the ecosystem around forms by maintaining a single version of the Forms package.
  3. Provide as much type-safety as possible, balancing against API complexity.
  4. Support gradual typing, allowing typed and untyped forms to be mixed.
  5. Ability to land the changes without breaking existing applications.

Non-Goals

  1. We don't intend to change template-driven forms. (see section on limitations below for more details)
  2. We also are not targeting non-model classes right now, such as Validator.
  3. 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 aFormGroup. 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.

constcat=newFormGroup({name:newFormControl('bob'),lives:newFormControl(9),});

Once the typed forms API is rolled out, interacting with thiscat form will be much safer than before:

constname=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 explicitany when constructing forms objects, thus aligning them with the current untyped semantics:

constcat=newFormGroup<any>({name:newFormControl<any>('bob'),lives:newFormControl<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 forany (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 thatnull is showing up in theFormControl types above. This is because form models can be.reset() at any time, and the value of areset() control is by defaultnull:

constdog=newFormControl('spot');// dog has type FormControl<string|null>dog.reset();constwhichDog=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 forFormControls to bereset to a default value instead ofnull:

constdog=newFormControl('spot',{initialValueIsDefault:true});// dog has type FormControl<string>dog.reset();constwhichDog=dog.value;// spot

This gives you a choice – we’ll provide as much type safety as possible for old uses ofFormControl, 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 ourcat type from above:

constcat=newFormGroup<{name:FormControl<string>,lives:FormControl<number>,}>(...);

In other words, aFormGroup'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:

interfaceCat{name:string;lives:number;}constcat=newFormGroup<Cat>({name:newFormControl('spot, …),,lives:newFormControl(9,),});

However, we want to strongly type not justFormGroup.value, butFormGroup.controls. That is, the type ofcat.controls.name should be the actual type of thename control, and not a plainAbstractControl type. This is only possible if the type ofcat is built on the control types that it contains, not the value types of those controls.

Disabled Controls

The value property of aFormGroup 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 ofcat.value in the example above looks like the interface:

interfaceCatValue{name?:string;lives?:number;}

This may seem surprising - any given key on the value object may not be present (and thusundefined if read). This happens because of the way disabled controls work in aFormGroup.When a control in a group isdisabled, 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.

Theget Method

AbstractControl provides aget method for accessing descendant controls by name:

constg=newFormGroup({'a':newFormControl('foo'),'b':newFormGroup({'c':newFormControl('bar')})});constval=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 astring variable), the return type will beany.

Adding and Removing Controls

FormGroup provides methods to dynamically modify its keys, such asremoveControl. In this proposal, such a call will only be allowed if the key is explicitly marked optional:

interfaceCatGroup{name:FormControl<string|null>,lives?:FormControl<number|null>,}constcat=newFormGroup<CatGroup>({name:newFormControl('bob'),lives:newFormControl(9),});cat.removeControl('lives');

In this example,lives can be removed because theCatGroup interface which describes theFormGroup specifies it as an optional property. If the? was not present in the type, then thelives key would not be removable.

Some applications useFormGroup as an open-ended dictionary, where the set of controls is not known at build time. For these cases, untyped forms can be used viaFormGroup<any>.

An alternative would be to introduce a new class,FormRecord, in which keys can be dynamically added and removed. The type guarantees forFormRecord would be much weaker than with immutableFormGroup.

FormBuilder

In addition to typing the model classes, we have also added types toFormBuilder. 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:

constb=newFormBuilder();consta=b.array([// A raw value'one',// A ControlConfig tuple['two',someSyncValidator,someAsyncValidator],// A boxed FormState{value:'three',disabled:false},// A controlb.control('four'),]);// ['one', 'two', 'three', 'four']constcounting=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 aFormControl 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 itsControlValueAccessor) is of the same type as theFormControl. That is, the following:

constname=newFormControl('name');// inferred type is FormControl<string|null><!-- error: string-valued FormControl bound to numeric-valued DOM control --><inputtype="range"[formControl]="name">

will result inname.value returning numeric values from the<input type="range">, despite being typed asFormControl<string|null>.

This is a limitation of the current template type-checking mechanism, due to the fact that theFormControlDirective which binds the control does not have access to the type of theControlValueAccessor 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 toNgModel 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:

  1. Go to thedemo StackBlitz project.
  2. Wait for all dependencies to be fetched and installed.
  3. Runng serve.
  4. Editprofile.component.ts to use the new typings.

To try it with a demo app locally:

  1. Download the demo app:git clone https://github.com/dylhunn/typed-forms-example-app.git && cd typed-forms-example-app
  2. Install all dependencies withnpm i --force -g yarn && yarn. As illustrated, you may need to force install them due to the experimental package versions.
  3. Run the app:ng serve --open
  4. Try out the new types by editingsrc/app/profile/profile.component.ts

To try it with your app:

  1. Ensure your app is on Angular13.x.x, upgrading if necessary
  2. Create a new branch:git checkout -b typed-forms-experiment
  3. Delete your node_modules folder:rm -rf node_modules
  4. Reinstall all dependencies:npm i oryarn
  5. Update your app to the experimentalnext release:ng update @angular/core --next. Yourpackage.json should now show that all angular packages are using the13.2.0-next.2 version or higher.
  6. Openpackage.json in your project’s root directory. Find@angular/forms, and replace~13.2.0-next.x withhttps://1106843-24195339-gh.circle-artifacts.com/0/angular/forms-pr43834-8e5ba4f698.tgz.
  7. Install the new dependencies (npm i oryarn, 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.
  8. Make a new commit:git add . && git commit -m "upgraded to experimental angular package versions"
  9. Run the migration:ng update @angular/core --migrate-only migration-v14-typed-forms.
  10. Your app should now build, andanys 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 aFormArray 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 astring and the second is anumber.

Our proposed design forFormArray 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 styleFormGroup?

In the current design, a typedFormGroup 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 usingFormGroup<any>.

An alternative would be to provide an explicitFormGroup 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 aMap<string, T>.

We would be interested in whether this kind of compound control ("FormRecord") would significantly improve the ergonomics of use cases whereFormGroup is currently used to manage a dynamic set of controls.

3. Is the currentnullability ofFormControls useful?

The original forms API allowed for initialization at construction to a specific value. However, controls would always usenull as a default value to which they would revert whenreset() - this means that all controls would necessarily benullable.

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 thenull reset behavior entirely, and always use the initial value as the default/reset value. To do this, in the future we will makeinitialValueIsDefault: 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 viasetValue 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 currentreset-to-null behavior is important.

4. Are the limitations involving control bindings a blocking issue?

As discussed above, binding to controls via directives (such asformControl andformControlName) 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.

You must be logged in to vote

Replies: 34 comments 80 replies

Comment options

Great idea! It would help a lot.

About this:

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.

I hope you'll mark it as a breaking change. Because I see already how this change would mess up a lot of the code where I expect "reset" to set nulls. It might lead to very cruel bugs, especially when forms are complicated (or have multiple levels of sub-groups).

I see your reasoning and I support it, but it will be a completely new behavior, so would be nice to use in the new code only, without removing the old behavior.

You must be logged in to vote
5 replies
@dylhunn
Comment options

Yes! When we finally do this, it will be a breaking change. We'll ship it with a migration schematic to keep code that relies on the previous behavior working.

@asincole
Comment options

I don't know, but how about adding a new method instead of changing the reset behaviour? something likeformName.resetToInitialValue() (or a better name). So the reset behaviour stays the same and this additional feature gets added?

@e-oz
Comment options

I really doubt that some schematic can deal with cases when reset() in one library, and code creating the form is in another. The new method is a much better idea.

@dylhunn
Comment options

how about adding a new method instead of changing the reset behaviour

@asincole The problem is that your form wouldn't be type-safe anymore. Consider the following:

constdog=newFormControl<string>('spot');dot.reset();console.log(dog.value);// null! This violates the type of `string`!

when reset() in one library, and code creating the form is in another.

@e-oz Indeed, this would be quite tricky to migrate. We'll need to think more about the migration story here.

@alxhub
Comment options

alxhubDec 16, 2021
Collaborator

I don't know, but how about adding a new method instead of changing the reset behaviour? something like formName.resetToInitialValue() (or a better name). So the reset behaviour stays the same and this additional feature gets added?

This is why (3) is an open question - we want to understand if the existing behavior around resetting tonull is valuable enough to keep around, or if we should pursue removing it entirely.

I really doubt that some schematic can deal with cases when reset() in one library, and code creating the form is in another. The new method is a much better idea.

We would probably make theinitialValueIsDefault flag mandatory, and a migration would add it where it's missing. Then in a future release, we could flip the default of that flag, and eventually deprecate and remove it.

Comment options

Thank you, Dylan (and the Angular team) for this RFC.

1. Is there a compelling use case for tuple-typed FormArrays?

I have not seen one compelling use-case for tuple-typed FormArrays thus far. When it comes to different values/controls in FormArray, I usually reach for FormArray of FormGroup instead and the group contains more information to differentiate the value types rather than using tuple-type.

// imagine like an array of Conditions to compare some data against[{ value: 'asd', type: 'TEXT'}, {value: 123, type: 'NUMERIC'}]

2. Is there a compelling use case for a map style FormGroup?

100%. Dynamic Forms is probably one of the reasons to use Reactive Forms in the first place.

3. Is the current nullability of FormControls useful?

I personally am aware of this and always reset to default values (we do have to keep track of the default values via a class property). So to me, it is not useful at all.

About this RFC which proposes to haveinitialValueAsDefault flag set totrue to change the behavior, I think it would be "nicer" to have this as the default behavior (reset to default value) and have migrations to addinitialValueAsDefault: false to the consumers' code.

Please do not repeat the ViewChild/ContentChildstatic flag situation.

4. Are the limitations involving control bindings a blocking issue?

I can see it as an annoyance that can be worked around with some strongly-typed pipes. But yeah, follow-up improvement with the Template Type Checker would be nice

You must be logged in to vote
2 replies
@alxhub
Comment options

alxhubDec 16, 2021
Collaborator

I think it would be "nicer" to have this as the default behavior (reset to default value) and have migrations to add initialValueAsDefault: false to the consumers' code.

I agree, it would definitely be nicer.

One of the things we consider when making these kinds of changes is the example code that exists in the wild. The problem with changing the default directly like this (even if we could make existing app code continue to work with a migration) is that example code in blogs, tutorials, etc would suddenly have different semantics, making them obsolete or at least extremely confusing.

So we usually prefer to make such changes slowly, by first introducing a flag and then flipping the default as a separate step.

@nartc
Comment options

This makes total sense Alex. I didn't account for existing tutorials out there. Thanks!

Comment options

On this question:

  1. 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?

I currently maintain a medium-sized enterprise application with some fairly involved reactive forms, and I believe handling this issue in some follow-up update in the future is perfectly fine. I would take whatever I could get on the TS side as soon as possible and worry about the template bindings side later.

You must be logged in to vote
0 replies
Comment options

YES PLEASE!!!!!!!!!!!

You must be logged in to vote
0 replies
Comment options

Hello,

Thanks for the awesome RPC and feature.

I wanted to ask a question about nullability of form controls. In current scenario, the returned FormControl from calling its constructor is type | null. We type our froms strongly with@ng-neat/reactive-forms. They return non-null types by default and we architected our code based around not having nulls on our models. We considered slowly migrating to official typed forms, but since new FormControl returns a nullable type, this would break A LOT OF our use cases(we use request types for transforming the model data to actual requests and those types are non-nullable mostly). And doing this in a 20-25 field model would get very tiresome really quickly:

image

Would it be possible to set this value on the from group that the form controls reside in, and can that turn all the form controls inside the group non-nullable types?

Or maybe provide another FormControl that returns non-nullable types(We can do this on our own though, no need to extend the API 🤔) I don't know just spitting ideas :)

I'm just trying to find a solution to a problem that we have, I don't know the angular base at all, just wanted to share a use case where nullable form control types can be annoying and hard to migrate.

Cheers!

You must be logged in to vote
5 replies
@dylhunn
Comment options

It's a bit confusing to allow aninitialValueIsDefault property on aFormGroup. Consider the following pedantic example:

constnullableDog=newFormControl<string|null>=('spot');constnonNullableCat=newFormControl<string>=('tabby',{initialValueIsDefault:true});constanimals=newFormGroup({cat:nonNullableCat,dog:nullableDog,},/* hypothetical property on a group */{initialValueIsDefault:true});

What happens here? The twoFormControls have already been constructed, so it's too late to change their types, and the types conflict with each other anyway.

I agree this could be a pretty verbose to add to all yourFormControls. Would it help if we provided a schematic to do it automatically? Or perhaps there is a simpler API possibility I haven't thought of.

@alxhub
Comment options

alxhubDec 16, 2021
Collaborator

They return non-null types by default and we architected our code based around not having nulls on our models.

@ng-neat/reactive-forms does not address thereset() problem -reset() on aFormControl<string> will set it to anull value in spite of its strongly typed nature. A similar problem exists withFormGroup.value in that package - its.value type doesn't reflect that individual keys may not be present if the underlying controls are disabled.

I agree that creating lots ofFormControls with options set is challenging. A helper method could make this simpler:

functionmakeFormControl<T>(value:T):FormControl<T>{returnnewFormControl(value,{initialValueIsDefault:true});}constfg=newFormGroup({alpha:makeFormControl('a'),beta:makeFormControl('b'),  ...});
@fatihdogmus
Comment options

@dylhunn Yeah I agree. Even if the confusing API is tolerated, the confusing type is problematic.

Would it be possible, say, if you explicitly set the type of the FormControl to string likenew FormControl<string>('value'), it is non-nullable but if you do not provide a type,new FormControl('value)', and the type is inferred from the initial value it becomes nullable? Because in current API, even if you set the type of theFormControl as string it becomes nullable so I think this might confuse people. People wouldn't expect a FormControl that they typed asstring and not asstring | null to be nullable. Making this choice explicit somehow, can solve our problem too, we would just type the FormControls with non-nullable types. I don't know if it is possible though, I'm not familiar with angular codebase, just some thoguhts :)

The schmetics could be really nice, like adding the initialValueIsDefault: true to all form controls. But this wouldn't help new code that will be written with the same verbose syntax. But if this is what is needed for typed forms, I'm happy :)

@fatihdogmus
Comment options

@alxhub Yeah I know, it is just a sweet delusion and a time bomb that is waiting to explode :) We generally try not to use reset or be careful when disabling forms, but it is a dangerous spot.

That was my thought exactly, extend the FormControl class with default values or a utility function.

@samuelfernandez
Comment options

@alxhub@fatihdogmus@dylhunn Please check here#44513 (comment) a proposal on how to implement strict nullability while giving hints to implementers. I have exactly the same feelings as you do, and I feel here we can do better.

Comment options

The proposal looks good, but I have a question around a specific implementation.

If we give the property of theFormGroup a type ofnumber, will the Forms parse this correctly?
e.g.

typeMyFormType={age:number;}constformGroup=newFormGroup<MyFormType>({age:newFormControl(0),});###<inputtype="number"[formControlName]="age"/>

When we performformGroup.get('age'), will this type actually be a number?

I've encountered problems in the past where I've expected the type to be a number, but because it has come from an input box on the HTML, it is still a string (as read fromHTMLInputElement.value), but TS will let you interact with it as a number, creating erroneous behaviour.

You must be logged in to vote
3 replies
@fatihdogmus
Comment options

AFAIU, you can't directly pass the type of the model itself to the group. The group expects a type that consistst of AbstractControls, like this(and you need to give it nullable sincenew FormControl returns nullable types):

typeMyFormType={age:FormControl<number|null>;}constformGroup=newFormGroup<MyFormType>({age:newFormControl(0),});

Then, you can access it direclty withformGroup.value.age orformGroup.get('age')?.value and as far as I tested, yes the actual value is a number, this code returns truetypeof this.formGroup.get('age')?.value === 'number'. The code I've tested with

<form[formGroup]="formGroup"><inputtype="number"formControlName="age"/><buttontype="button"(click)="click()">Hebele</button></form>
typeMyFormType={age:FormControl<number|null>;};@Component({selector:'profile',templateUrl:'./profile.component.html',styleUrls:['./profile.component.css'],})exportclassProfileComponent{formGroup=newFormGroup<MyFormType>({age:newFormControl(0),});click(){//prints 'true'console.log(typeofthis.formGroup.get('age')?.value==='number');}}
@dylhunn
Comment options

@fatihdogmus is correct. Just one addition: thenull is optional, you can get rid of it by specifyinginitialValueIsDefault on theFormControl.

@Coly010
Comment options

This is great to know! That’s awesome 🎉

Comment options

First of all - a great thing to finally have built in typed reactive forms! I am very excited to use it.
Now my opinion on some stuff:

  1. Concerning FormRecord - I think this is a must. In addition, i think a typed key is required too... Some forms must have all keys as an enum, some might even be a Partial Record.
  2. I dont think the missing types in some areas are a blocker... I do think validators and CVA needs to be typed in future releases. One more thing is formControlName validating that the name actually exists in the parent form group. Didn't see it mentioned in addition to the internal type check.
  3. The reset to initial sounds great and should be the deafult. Generally i believe angular should discourage null usage and prefer undefined
You must be logged in to vote
3 replies
@the-ult
Comment options

  1. The reset to initial sounds great and should be the deafult. Generally i believe angular should discourage null usage and prefer undefined

Indeed. Angular should provide the best practices/preferred way as default. And provideopt out or extra settings for backwards compatibility.
This way you discourage thewrong(ish) way, instead of the other way around. If doing it the proper way, costs more time/work people might take the easy way.

@bbarry
Comment options

I agree on theFormRecord type - yes please. Ideally I could do something like this:

constb=newFormBuilder();constform=b.record<Pledge&{[key: `comment-${keyofPledge}`]?:string;}&{[key: `allocation-${keyofPledge}`]?:number;}>()

and get a type error if I add a control to the form that is not a valid key of this object.

@mbeckenbach
Comment options

„ I dont think the missing types in some areas are a blocker... I do think validators and CVA needs to be typed in future releases. One more thing is formControlName validating that the name actually exists in the parent form group. Didn't see it mentioned in addition to the internal type check.“

This is actually really an important thing. That’s why I use property bindings all the time when using nostack forms. Typos in control names happen so often. Actually this is the main reason for me to use no stack forms. :)
Also refactoring works then fine.

Comment options

Thank you for this awesome RFC 💪

Backwards Compatibility

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:

constcat=newFormGroup<any>({name:newFormControl<any>('bob'),lives:newFormControl<any>(9),});

It might be an idea to provide an extra option for theng update. So you can choose whether you want the<any> or rather the strong typed (and having your builds broken, so you can fix them)? So: "opt out of the ng updateopt out " 😇
Or the other way around. Default to strong typed, but giveng update a way so it will show a prompt when existing forms are found. In which you can choose the option for your project/workspace. (opt-out or not). e.g.

Existing forms found:- Would like to apply strong types for your existing forms? (might cause breakage): Y/n   See: https://angular.io/docs?explain-how-why-etc
You must be logged in to vote
4 replies
@allout58
Comment options

At the very least, make the migration for the<any> a separate migration from any other changes for this RFC, so if you use a third-party tool like Nx that allows you to change what migrations you run you can just disable it before executing all the other migrations.

@dylhunn
Comment options

The migration actually applies a special aliasAnyForUntypedForms for exactly this reason, so it should be quite easy to do a search and replace in your project to remove them all.

@the-ult
Comment options

Well, since every property, properly has a different type. You'll have to change them one by one. So a search and replace will probably not work, right?

@alxhub
Comment options

alxhubDec 27, 2021
Collaborator

I expect in practice you'd do a search-and-delete :)

Comment options

Your work looks great, thank you.

This is what I think about this topic: (I didn't read the other comments, so please forgive me if you answered some already)

  • The behavior of thereset() method is misleading and prone to errors. It would be better if the method returned the initial control value. To handle the current behavior in my library, you must explicitly declare that the control can be null. (e.gFormControl<string | null>(''))

  • I believe that the current edition of thetypes functionality is just one piece of the puzzle. Users want models and forms to be linked together. For example:

interfaceProfile{firstName:string;lastName:string;address:{street:string;city:string;};}constprofileForm=newFormGroup({firstName:newFormControl(''),lastName:newFormControl(''),address:newFormGroup({street:newFormControl(''),city:newFormControl('')})});

I expose aControlsOf type in my library that provides this functionality. You may not be responsible for providing one, but it'd be nice if you could ensure that users can create one themselves.

  • In my opinion, the behavior of thedisabled control is undesirable. It isn't the library's responsibility to decide whether or not to remove a control value from a group. One of the surprising features people encounter when accessing a group value is this functionality.

  • There isn't a valid case for tuples, as far as I can tell. AFormArray has the same type ofcontrols.

  • I'm wondering if you have considered the following case:

// root.componentclassRoot{group=newFormGroup({})}// child.componentclassChild{constructor(root:Root){root.addControl('key',newFormControl(''))}}

Moreover, dynamic forms libraries such asformly are worth looking at.

  • What about typing theControlValueAccessor class? We can pass a genericControlValueAccessor<string> that'll describe the expected type.
You must be logged in to vote
5 replies
@cexbrayat
Comment options

When I was playing with earlier versions of this PR, I felt the need to have anInferControls type, which was basically a simpler version ofControlsOf that@NetanelBasal mentions. I agree that a similar type out-of-the-box would be handy. Even if that won't cover all cases, this would simplify a lot of common use-cases.

@dylhunn
Comment options

@NetanelBasal Thanks for your insightful comment.

I expose aControlsOf type in my library that provides this functionality

We will almost certainly add aGuessControlType or similar based on this feedback. I'm yak-shaving a bit about the name because we cannot actuallyguarantee that the result is correct, due to object-valued custom controls.

There isn't a valid case for tuples, as far as I can tell. A FormArray has the same type of controls

What about[string, number], which is not possible with the currentFormArray?

In my opinion, the behavior of the disabled control is undesirable.

I agree this behavior is confusing and bad, but we don't have a strategy for migrating/evolving away from it. Any ideas here? (Of course, we could just tell everyone to usegetRawValue() and deprecatevalue, but that's very surprising.)

I'm wondering if you have considered the following case

You can type this three ways with the current prototype, with increasing levels of strictness:

  1. FormGroup<any>
  2. FormGroup<{[key string]: FormControl<string>}>
  3. FormGroup<{key?: FormControl<string>}>
@NetanelBasal
Comment options

We will almost certainly add a GuessControlType or similar based on this feedback. I'm yak-shaving a bit about the name because we cannot actually guarantee that the result is correct, due to object-valued custom controls.

Yes, that's correct. Custom value accessors are pretty standard. For example, you'll almost always useFormControl([]) in custom select elements. I'm forcing the consumer to explicitly pass it in my library to get around this problem:

interfaceUser{name:string;// 👇🏻skills:FormControl<string[]>;}

What about [string, number], which is not possible with the current FormArray?

Same. From my experience, you'll usually use the same control type, but let's hear what others have to say.

I agree this behavior is confusing and bad, but we don't have a strategy for migrating/evolving away from it. Any ideas here? (Of course, we could just tell everyone to use getRawValue() and deprecate value, but that's very surprising.)

Now would be a good time to begin the process. Otherwise, it won't happen. You could introduce agetValue() method that returns the same value asgetRawValue() and deprecate bothvalue andgetRawValue(). Then, it could be removed in v14/15 as you decide.

@Harpush
Comment options

I totally agree disabled is currently working in a weird and unexpected way. getValue seems like a good idea!

@flensrocker
Comment options

getValue(): don't forgetvalueChanges - how should that be migrated to a "not remove disabled controls" thing?

I almost usegetRawValue because I got very surprised about the behaviour ofvalue.

Comment options

I understand that this is probably a much heavier breaking change than you want to take but I feel like almost all of theany types in this demo should really beunknown

Having an implementation of a strongly typedFormBuilder andFormControl andFormGroup (we don't useFormArray) we are using a value class with an input type like this:

typeValueOf<T>=numberextendsT ?number|undefined :booleanextendsT ?boolean|undefined :T;typeTypeName<T>=Textendsstring  ?'string' :Textendsnumber ?'number' :Textendsboolean ?'boolean' :never;typeTypeFromName<T>=Textends'string' ?string :Textends'number' ?number :Textends'boolean' ?boolean :never;functionsanitizeBoolean(value:unknown):boolean|undefined{if(typeofvalue==='boolean')returnvalue;if(typeofvalue==='string'){if(value==='true')returntrue;if(value==='false')returnfalse;}returnundefined;}functionsanitizeNumber(value:unknown):number|undefined{// ...}functionsanitizeString(value:unknown){// ...}classFormControlTyped<Textendsstring|number|boolean,NameextendsTypeName<T>>{constructor(private_value:ValueOf<T>,privatereadonly_type:Name){}privatetypeGuard<TUnknownextendsstring|number|boolean,UnknownNameextendsTypeName<TUnknown>>(check:UnknownName):this isFormControlTyped<TypeFromName<UnknownName>,TypeName<TypeFromName<UnknownName>>>{// without the cast, TS2367: This condition will always return 'false' since the types 'Name' and 'TypeName<TUnknown>' have no overlap.returnthis._typeasunknown===check;}getValue():ValueOf<T>{returnthis._value;}/**   * sets value of form control   * setting to undefined will reset to initial value   * setting to empty string will set the DOM control value to empty string (potentially an invalid control state)   */setValue(v:T|''|undefined){// ...if(this.typeGuard('string')){// without the cast, TS2345 : Type 'string' is not assignable to type 'ValueOf<T> & string'.this._value=sanitizeString(v)asany;}if(this.typeGuard('number')){this._value=sanitizeNumber(this._value)asany;}if(this.typeGuard('boolean')){this._value=sanitizeBoolean(this._value)asany;}thrownewError('type is invalid');}}

capturing this type name for FormControl obviously is a much bigger breaking change than anything you are considering but the type considerations forValue are what I am trying to get across.

You must be logged in to vote
1 reply
@alxhub
Comment options

alxhubJan 3, 2022
Collaborator

The problem withunknown is that it would break existing code, which goes against one of our primary goals.

Longer term, we hope to remove the defaults for the generics from this proposal, so thatFormControl & friends will always need a generic parameter specified (unless it can be inferred as withnew FormControl(...)). So theanys hopefully won't be around forever.

Comment options

It looks likeFormArray is migrated toFormArray<AnyForUntypedForms[]>. Is[] necessary?

It feels redundant, as a FormArray returns an Array. Typing itFormArray<Something> instead ofFormArray<Something[]> would be shorter, would remove the need for a special case in the migration, and would be more aligned with how APIs usually look like in TS.

You must be logged in to vote
2 replies
@dylhunn
Comment options

We've been thinking about this quite a bit.

Pros of usingFormArray<ElemType[]>:

  1. FormTuple, if implemented, would look likeFormTuple<[string, number]>, so the array type is consistent between the two classes
  2. Implementation is easier, sinceT is directly the control type

Pros of usingFormArray<ElemType>:

  1. Looks like the builtinArray<T>
  2. More consistent withFormControl andFormGroup
  3. More consistent migration

If we don't implementFormTuple then we will certainly change it. Otherwise it's still up in the air, and we'll probably go where the consensus leads us.

@flensrocker
Comment options

I vote forFormArray<ElemType>. That is much clearer.

Comment options

The migration does not seem to pick up some components. In particular, it looks like it ignores all lazy-loaded components right now. I opened an issue with a repro, see#44524

You must be logged in to vote
2 replies
@dylhunn
Comment options

Thank you for reporting this -- this is a major blocking issue we will need to solve.

@alxhub
Comment options

alxhubJan 3, 2022
Collaborator

Yep - likely we need to look for dynamicimport() as well as staticimport ... from statements.

Comment options

To play devil's advocate, the current permissive typing enables users to opt-in to stricter typing by defining interfaces that extend the@angular/forms classes. For examplehere is a genericControlValueAccessor interface extension that I'm using in a project currently. In the past I've done similar things forFormGroup,FormControl,AbstractControl, etc., and@ngneat has a library that does this as well. With the current Angular types, anything defined asany can be trivially replaced by a generic parameter without TypeScript complaining about it.

So I guess my concern is, how would these changes impact/interact with implementations like mine or@ngneat's? For example, I'm not clear on what will happen to Angular'sControlValueAccessor interface. I assume that any Angular types which become generic would cleanly supersede any custom generic interfaces, but ifControlValueAccessor will remain as it is currently, butFormControl gets a new generic parameter, would the stricter typing ofFormControl potentially cause any type conflicts with my hand-rolled genericValueAccessor interface?

You must be logged in to vote
3 replies
@dylhunn
Comment options

Regarding extending Forms classes:

Note that we are providing a default typeT = any for all these classes. Any library that declaresMyFormControl extends FormControl is implicitly extendingFormControl<any>, and should continue to work. This is still a theory, and we're very interested in finding out if we broke applications that rely on this approach. Please help us by trying it out and letting us know :)

Note also that extending Forms classes like this is technically unsupported and we recommend against it. We're trying to avoid breaking it though.

RegardingControlValueAccessor

It's becoming more and more clear that we need to typeControlValueAccessor as well. This will probably be a simple<T> on the interface, which will beused inwriteValue and theonChange callbaek.

@Harpush
Comment options

Regarding extending form controls - why is it an unwanted behaviour? If for example you have a reusable CVA component which encapsulates a form group with built in validation, currently you have to render it even if not shown to get the validation to work (required validation for example). You could for example extend the form control to encapsulate the validation logic and use it with the now "stateless" component

@alxhub
Comment options

alxhubJan 3, 2022
Collaborator

opt-in to stricter typing by defining interfaces that extend the @angular/forms classes.

It's very difficult to get the types right while doing this, due to the many gotchas (reset() and nullability, disabled controls, etc). We studied many different implementations both inside and outside of Google, and they all suffered from unsoundness related to these strange cases (including@ngneat).

So I guess my concern is, how would these changes impact/interact with implementations like mine or@ngneat's? For example, I'm not clear on what will happen to Angular's ControlValueAccessor interface. I assume that any Angular types which become generic would cleanly supersede any custom generic interfaces, but if ControlValueAccessor will remain as it is currently, but FormControl gets a new generic parameter, would the stricter typing of FormControl potentially cause any type conflicts with my hand-rolled generic ValueAccessor interface?

This is a good question! My suspicion would be that custom derived interfaces will either 1) be migrated or 2) benefit from the default generic parameters ofany that we're adding toFormControl, etc, so that they will interact using the previous untyped behavior. That is:

classMyFormControl<T>extendsFormControl{  ...}

implicitly extendsFormControl<any>, so the base class behaves as an untypedFormControl.

Therefore, there should be no direct conflict, but your custom interfaces would need to be updated in order to benefit from any stricter typing.

Comment options

I really love this and can't wait to see it implemented. I'll play a bit with the example, but love everything you're thinking and how you plan to solve some issues now and in the future (i.e. reset(), default value vs null by default).
Thank you very much, I really like that Angular is becoming better version after version and implementing/solving what the community has been discussing and commenting all these years.

You must be logged in to vote
0 replies
Comment options

constdog=newFormControl('spot',{initialValueIsDefault:true});// dog has type FormControl<string>dog.reset();constwhichDog=dog.value;// spot

It would be nice to be able to set the default value asynchronously.

Something like this:

constdog=newFormControl('spot');// dog has type FormControl<string>this.getValueFrombackend.subscribe(value=>dog.setValue('beSpot',{initialValueIsDefault:true}));// ...few moments latter...dog.reset();constwhichDog=dog.value;// beSpot
You must be logged in to vote
4 replies
@dylhunn
Comment options

What would happen if you callreset before thegetValueFrombackend event fires? And what would the type ofdog be?

@LendsMan
Comment options

Let's rename it a little bit to make more sense.

constdog=newFormControl('spot');// dog has type FormControl<string>dog.reset();console.log(dog.value);// null | current behaviour `Default is null`this.getValueFrombackend.subscribe(value=>{dog.setValue(value,{asDefault:true});// beSpotdog.setValue('random');dog.reset();console.log(dog.value);// beSpot});/* Maybe even like this?!this.getValueFrombackend.subscribe(value => {    dog.setDefaultValue(value); // beSpot    dog.setValue('random');    dog.reset();    console.log(dog.value); // beSpot})*/

I believe It should be reset to the latest default value or fall back to the current behavior.

It shouldn't affect types at all. It would behave like a simple setValue but with changing the default value.

@dylhunn
Comment options

In the example you gave, the type ofdog.value isstring, but the value isnull. That means the type would be lying. We definitely want to avoid that.

@alxhub
Comment options

alxhubJan 3, 2022
Collaborator

We could definitely consider allowing the default value to be updated in the future - as long as the new default value is compatible with the type of the control, this should work fine.

Comment options

The get Method

As long as a constant string literal is provided as the argument, we will tokenize it and extract the type of the requested control.

constval=g.get('b.c')!.value;// 'bar', has type string|null

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

Allowing to perform certain actions to controls based on the typing is a wonderful idea. However, I think we should add something more to this. Currently, controls can be added after the instantiation of a form group, that is why usingget could always return undefined. We can't guarantee that it exists right now.

I feel this the suggested approach of using non-null assertion operator hasdrawbacks:

  • Pushes the responsibility to developers to ensure that controls are not used before being declared. In complicated scenarios building dynamic forms that would be complicated to ensure and false assumptions could be made. We'd rely in runtime to assert it works.
  • When strict null checks landed into Angular, many projects embraced the linting ruleno-null-assertion. Actually, it is part of the recommended configuration. As it mentions in its documentation:

Using non-null assertions cancels the benefits of the strict null-checking mode.

Suggestion for an enhancement

  • Using typing,distinguish when a form group has its controls declared on instantiation, and when they are not and should be considered dynamic.
  • In the first case, disallow adding and removing controls. When usingget we cansafely return the control without the need to use!
  • In the second case, it is understood that controls will be added dynamically at a later time. In that case, the app should make sure that controls exist before using them

This shouldn't be hard to implement in TS, yet would provide better ergonomics. Additionally, it will move Angular and this new feature into the recommendedeslint rules, a vital part of the ecosystem of wide use.

You must be logged in to vote
0 replies
Comment options

Disabled Controls

When a control in a group is disabled, its value is not included in the value object

As mentioned, this is surprising, and will affect the expected benefits of strict typing. After putting a lot of effort into creating models, type form controls and so on, you want to safely use the value of the form group and end up having a partial value. True, you can always userawValue(), but being honest almost everyone usesvalue.

Proposal

  • Use a similar approach as described here[Complete] RFC: Strictly Typed Reactive Forms #44513 (comment) for thereset functionality. Let the model drive the decision of what actions can be made
  • Disable thedisabled property (pun-intended) and method if the value's type is not optional
  • This would enforce developers to use an optional value when they want to use disabled controls
You must be logged in to vote
3 replies
@bbarry
Comment options

While it might be surprising to some, it is how the value object currently behaves and this proposal doesn't intend to make any runtime changes to forms.

Unfortunately that means that the resulting typed forms aren't strictly safe, for example you could have aFormControl<number> that is used with a custom input that happens to use aControlValueAccessor<any> and sets the value to be a string every time, and then when you get.value of the control your value will be typed as anumber but the actual value will be astring.

@jaschwartzkopf
Comment options

I would love to see a readonly state added. If readonly the value would still exist in value, validators would still run, etc. The CVA interface could have a new optional setReadonlyState to set that, and if not it would set the disabled state on the CVA (assuming it at least has a setDisabledState).

Most of the time in my current application when we are disabling the form we'd really rather just set inputs to readonly, but that's not easy to do with reactive forms.

@mbeckenbach
Comment options

Fully agree. Readonly should exist in the same way as disabled exists.

Comment options

  1. Disabled controls, or members of disabled fieldsets vs the type
typeParty={address:{house:number,street:string,}, ...}

What ifaddress becomes a fieldset and gets disabled by UI, the values ofhouse andstreet will be skipped from value (will only remain available ingetRawValue()), so the type of the entire form will becomePartial<Party>. Is that correct?

  1. Control value type vs HTML

Will the html control be of typenumber ortext?

<input formControlName="age">

age = new FormControl<number>()

Which type will prevail here?:

<input formControlName="age" type="number">

age = new FormControl<string>()

You must be logged in to vote
2 replies
@dylhunn
Comment options

What ifaddress becomes a fieldset and gets disabled by UI, the values ofhouse andstreet will be skipped from value (will only remain available ingetRawValue()),

Yes, that's right.

so the type of the entire form will becomePartial<Party>. Is that correct?

The type of.value is alwaysPartial<Party> for this reason.

Which type will prevail here?:

We currently don't have the ability to check the types against the HTML element. As discussed in theControl Bindings section above, we plan to fix this in a future version, but it would not be part of the initial release. To be more exact, in your example the typestring would prevail, but would not necessarily be correct if the bound input is inserting numeric values.

@moniuch
Comment options

Shouldn't FormGroups have.rawValueChanges stream which would always include all controls and provide a fully typed value (ie. completeParty rather thanPartial<Party>)?

Comment options

Migration toany

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

Is there any way a migration thatdoesn't addany type parameters could be included? Or a different but optional migration to remove theany type parameter added by the primary migration?

You must be logged in to vote
3 replies
@dylhunn
Comment options

Great question. For this reason, the migration will actually insert a special symbolAnyForUntypedForms, which is an alias forany. So if you want to remove it, you can simply find and delete all occurrences of<AnyForUntypedForms> across your project.

@LayZeeDK
Comment options

Sounds like something that could be automated 😉

@mbeckenbach
Comment options

I already see some linters scream because of any. :-)

Comment options

I'm following the instructions and getting Artifact not found
https://1097395-24195339-gh.circle-artifacts.com/0/angular/forms-pr43834-a245792aa2.tgz

You must be logged in to vote
1 reply
@dylhunn
Comment options

It looks like my artifact expired off of CircleCI. I will build a fresh one, and update the instructions in about 45 minutes.

Comment options

In the stackblitz example reset code does not work:

const dog = new FormControl('spot', {initialValueIsDefault: true}); // dog has type FormControl<string>dog.reset();const whichDog = dog.value; // spot

dog.value is setnull

After looking at the code, seems like default value has to be provided as a 4th argument, but that is not reflected in types.

e.g.

const dog = new FormControl('spot', {initialValueIsDefault: true}, [], 'spot'); // dog has type FormControl<string>

Would work

You must be logged in to vote
2 replies
@dylhunn
Comment options

Thanks, this appears to be an error in the package version on StackBlitz. It is intended to work as in your first example, and this will be fixed for the released version.

@dylhunn
Comment options

The new link ishttps://1106843-24195339-gh.circle-artifacts.com/0/angular/forms-pr43834-8e5ba4f698.tgz, and I will update it above.

Comment options

It's possible to get non-existing control without getting any errors, e.g.

profileForm.get('nonExistingPirojokControl')!.value

It seems like there are some checks for it, but this should not compile since the forms are not dynamic

You must be logged in to vote
0 replies
Comment options

I love that approach a lot. I was using @ng-stack/forms until angular has typed forms itself. There are some other 3rd party libs that try to solve the same issue. Until now feels wrong that in an Angular project everything is typed but when you come to a more complete thing like forms you are back in the untyped magic hell.

Regarding Question 3
I would like to see the initial value used as a default for reset. I agree that it should not be a breaking change for people that might rely on the current null values. Setting the default for initialValueIsDefault on a global level might be a good idea. Maybe by some injection token or so.

Regarding Qestion 4:
A template checking would be very helpful. I noticed several times already that people are using input controls which have a string value for form controls that should get numeric, boolean or date values. As such can cause many bugs it would be good to have such a validation. While i could imagine to make this optional using some compiler config flag.

You must be logged in to vote
5 replies
@mbeckenbach
Comment options

@samuelfernandez wrote "Leave reset() as it is, setting the value to null. It has always been that way, tutorials explain it."

That is a good point. That's why i think the behavior should be configurable on a global level. Default should be like it was before. But lets say you want that in your project initialValueIsDefault=true is used always, a global setting would be nice and reduce time spent in code reviews. :-)

@flensrocker
Comment options

Wouldn't a global setting be used by imported 3rd party modules?
That could lead to surprising behaviour.

@mbeckenbach
Comment options

I am not sure if I get your point@flensrocker

@flensrocker
Comment options

How do you ensure that a global setting of initialValueIsDefault only applies to your forms but not to forms in a 3rd party module? It may not work because it expects the value to be null after a reset.

@mbeckenbach
Comment options

Hm… valid argument. Even if it was passed in as a provider the 3rd party module would need to overwrite it.

What about a config property of the component that hosts the form? Similar to change detection on push.

So i can set it on any level in the component tree. And just like with on push I can opt in at component level or set it at app component and opt out if some piece of my app does not play nice with it.

Comment options

Thank you for this RFC and all the work that has gone into this issue. We use@ngneat/reactive-forms today to obtain as much type-safety as possible given current limitations.

This design is excellent. The biggest change I would like to see is adding strongly-typedControlValueAccessor andValidatorFn/AsyncValidatorFn. I'm hesitant to suggest anything which could slow releasing this proposal, but these are so integral and coupled to reactive forms that omitting them seems like a half-measure. Better to require devs to remove all theAnyForUntypedForms once instead of two passes fixing validator/formcontrol/CVA type mismatches.

  1. Is there a compelling use case for tuple-typed FormArrays?

I haven't seen one; I can't envision a case where a FormGroup wouldn't work well enough.

  1. Is there a compelling use case for a map style FormGroup?

Yes: I like theFormRecord<TKey, TValue> idea, primarily for use cases like a checkbox list or a settings page.

My preference is delineating form control containers as follows:

  • FormGroup: Strongly typed, immutable except for FormControl properties that can beundefined
  • FormArray: Homogenous array (useany or unions for heterogenous types)
  • FormRecord: Dynamic list with typed key and homogenous values (also useany or unions for heterogenous types)

REFormRecord, it would be great to be able to use enum types for keys.

  1. Is the current nullability of FormControls useful?

Not as implemented - the current behavior ofreset() is unintuitive and causes typing bugs. Resetting to an initial or default value would be preferable.

The disabled formcontrol behavior causingundefined values is also unintuitive and the source of typing bugs. Just because a formcontrol is disabled doesn't mean the FormControl value should be omitted. If the value should change, the developer can set it when disabled status is set.

  1. Are the limitations involving control bindings a blocking issue?

Not blocking, and I don't see how it could be - it doesn't make things worse than they are today. But it would be great to have confidence that FormControl types and CVA types match up, if you can make that work.

You must be logged in to vote
2 replies
@mbeckenbach
Comment options

I disagree. Disabled means the form field cannot be filled out. So the value is not relevant and not validated.

If the field should be in the submitted form value, then the field should be set to readonly instead of disabled as it should also be part of the validation.

Unfortunately reactive forms does not cover readonly FormControls yet. So it must be done on template level. As I wrote above it would be great to add this support to it.

@johncrim
Comment options

I assume you're disagreeing with my statement about disabled formcontrols. The problems I have with the current behavior are:

  1. It widens the type offormControl.value to includenull andundefined, where that may not make sense. The correct value for disabled should be defined by the developer.
  2. In many cases it adds unnecessary type-handling logic to convert null/undefined to valid values, including in cases where the control will never be disabled.
  3. It can also prevent use ofnull andundefined for other purposes within theformControl.value type.

I think it would be cleaner/more explicit to allow the developer to define the valid types offormControl.value, and set the disabled value manually when needed.

Alternatively, I could see value in addingformControl.disabledValue (defaultundefined for backward compatibility) in addition toformControl.defaultValue (defaultnull for backward compatibility). That way, for example, the type offormControl.value could bestring iffdefaultValue anddisabledValue are both string.

Comment options

Is there a chance that the typing also allows us to query which validators a control has? A common issue is that form controls are required but someone forgets to add the * in the template. Material lib for example does not know if the reactive form control is required so you always need to set the attribute manually.

You must be logged in to vote
2 replies
@dylhunn
Comment options

This is actually already possible, as of Angular 12.2. UsehasValidator:myControl.hasValidator(validators.required);

@mbeckenbach
Comment options

Oh nice. I never noticed this. Thanks!

Comment options

Hi, thank you for this RFC. It seems really great.

  1. The migration took care of the project and I don't recall any breakage brought by it.
  2. The tuple instead of array might sound great, but I guess it's way too much for now and I'd suggest to put it aside for now.
  3. I wish I could say the same about dynamic FormGroups but the ability to change them as-you-go is really needed. Thankfully, there is a workaround, but I hope to see this implemented really soon.
You must be logged in to vote
0 replies
Comment options

First off, very, very GLAD to see the Angular team working on strongly typed forms! Very exciting!! Can't wait for this to land. Also appreciate the RFC. Gives me the opportunity to comment on an issue I've been facing recently with Angular 12, and this concept of expanding types with| null, an issue that I think is extremely important.

So, without further ado, one of the things that worries me about Angular, is that it has the tendency toexpand types. With TypeScriptstrict mode, the use of nulladds another factor to a type. This can causenull to become a problematic, infectious "hanger's on" in types in Angular. I opened an issue some months back about this type expansion issue with theAsyncPipe, which forces the developer to add| null to ALL@Input()s on their child components in order to be compatible withasync:

#43727

For some background, I prefer not to usenull as a matter of course. There are nuanced differences betweenundefined andnull that have given me reason to preferundefined overnull, and with TypeScript strict mode the need to include "nullability" to my types requires a lot of extra| null typing that I would greatly prefer to avoid, as it can lead to a fair amount of additional keyboard typing in a codebase, and ends up becoming "just another thing to go wrong" in my code bases.

TypeScript strict mode can end up causing| null to become rather "infectious" by requiring that extra type aspect to be included on many "downstream" types, or force the developer to deal with potential nulls as well as simple "optionality" (undefined, which IMO are easier to deal with.) Simply usingundefined until such time as anull becomes a strict requirement for some specific reason leads to a cleaner, simpler code base. Further,undefined properties do not normally "serialize" with JSON.stringify() whereasnull properties DO, and this can lead to leaner wire-level data whenundefined is used instead ofnull unless you actually want/need to deal with and storenull in APIs and the like.

Finally, I tend to use TypeScript in a very "Clojure-like" way, following much of the philosophy Rich Hickey takes when it comes to software development. I started learning about Clojure several years ago, and greatly enjoyed Hickey's simple, data-oriented, and highly functional approach to solving problems that often become unnecessarily complex in other languages/platforms. I've employed a lot of his philosophy in a very functional approach to writing TypeScript, which includes preferring "non-existence" to "having this special value called null" or this concept that something "may exist, or maybe not" (both concepts, as it turns out, tend to be "infectious" to code bases in one way or another...the Maybe monad is definitively infectious). That part of his philosophy is embodied in this talk he gave some years ago:

https://www.youtube.com/watch?v=YR5WdGrpoug

Well worth the time, for anyone who is trying to use TypeScript in a functional manner. Wonderful stuff! Specifically speaking, time index 8:00 in the above video embodies a critical factor. Hickey states it quite clearly: An "easing" of requirements should be a "compatible change", however with TypeScript strict mode, this is no longer true when adding| null...the null becomes an issue that can spread through the codebase. Usingundefined on the otherhand, is implicit and natural, simply by adding optionality, which is endemic to JS and TS.

So, with that, I read this in the first post, and it gave me great concern:

constname=cat.value.name;// type `string|null`cat.controls.name.setValue(42);// Error! `name` has type `string|null`

The types listed here arestring|null. This means that, if a codebase does not otherwise "normally" usenull in their types, then they will HAVE to be expanded to include| null. IMO, most use cases would be where a form is intended to match a model, whereoptional properties (which areundefined in nature, NOTnull!!!) would commonly be used:

interfaceMyModel{name?:string;}

In order for a model like this to be compatible with a form that always EXPANDS the natural type to include the, as of TypeScript Strict Mode, UNNATURAL option| null, the model would have to be updated as well:

interfaceMyModel{name?:string|null;}

Not only is this type expansion potentially unwanted, it adds complexity to code that should just be a simple optional property. Further, the use of null in the model, would then require the developer deal with the possible nulls elsewhere, potentially requiring| null to be added to other types, thus infecting larger and larger portions of an otherwise clean, nullable-free codebase.

Once again, the null isinfectious and has to grow throughout the codebase, because the underlying framework is ADDING potentiallyunwanted nullable type expansions due to the currently proposed typed forms design. As with myAsyncPipe recommendation, I strongly believe Angular should always opt for the NARROWEST type expansions possible, whenever possible. Null is not always a wanted or accepted value in some code bases (in mine in particular, I avoid nulls unless I have a very, very explicit need for them; generally I preferundefined for "non-existent values" if I can, in part because undefined properties do not "serialize" when converting native JS objects to JSON, whereas null properties DO).

I would always prefer the EXPLICIT option to choose to use null if and when I choose, rather than be forced to deal with them. I very strongly believe Angular should, in all cases and at all opportunities, avoid EXPANDING types to include|null if they were not originally including it. I respectfully request that Angular aim to support the "natural" type for "can be T or otherwise it does not exist" which would beT | undefined or in the case of parameters or optional properties?: T. This allows developers who have explicit use cases for nulls, but otherwise prefer to simply opt for non-existence (undefined) when values might not exist, to code the way they prefer, and avoid the need to infest their code bases with| null.

You must be logged in to vote
2 replies
@the-ult
Comment options

Same here. We try to useundefined instead ofnull and use theunicorn/no-null rule to help us with that.

@dylhunn
Comment options

As discussed below, we are forced intonull because that's the current behavior ofreset, but we are planning on fixing this (by removing thenullability altogether).

Comment options

After several weeks of discussion, the Typed Forms RFC is now closed.

Based on the feedback and the initial prototype, we plan to move forward with the proposed design. We’ll provide more updates as we progress with the implementation and incorporate your feedback, so stay tuned! In particular, a couple action items stand out:

  • We will not block this feature on template type checking improvements, validators, or CVAs. We may land improvements in these areas, possibly in follow-up releases.
  • We’ve identified some use-cases whereFormRecord (but notFormTuple) might be useful and we’ll consider adding it as a followup.
  • Our approach to nullability has tradeoffs, but seems to be an acceptable non-breaking solution.
  • We will fix some implementation issues uncovered by participants.

Thanks to all the participants for your help evolving the Angular framework!

You must be logged in to vote
14 replies
@dylhunn
Comment options

@jrista Right. I agree with you thatundefined is better thannull, and if we were designing a new API today we would not usenull. Unfortunately,reset currently usesnull, so our hand is forced. Getting out of that situation is going to take us a little while.

@jrista
Comment options

@dylhunn Yup, that is understood. Just wanted to lay out the benefits of usingundefined and howT | undefined is different, and a more "compatible change" as Hickey would put it, thanT | null so that it was all clear (not just for the Angular team, but for anyone else reading the RFC.)

In re-reading the RFC at the top. There were some extra notes about supporting resetting to the default values. It sounds like that capability will be an option that can be enabled on day one? I am wondering, depending on the config settings the end developer uses...could that be used to dynamically adjust the return type of form value types? I've seen some incredible typing functionality with things like NgRx, where depending on configuration objects (say with thecreateEffect function), the return type of the function is determined by usage. ThecreateEffect function may expect an action to be emitted by the observable under default circumstances, however it is possible to pass in a config object that disables dispatch{ dispatch: false } and that dynamically reconfigures the return type expectation. Pretty amazingly powerful stuff. I wonder if some careful typing might allow you guys to dynamically address the| null stuff sooner rather than later? Maybe with some global config when importing the forms module?

Anyway. This will be my last request, I promise. ;)

@samuelfernandez
Comment options

@dylhunn would it be possible to remove the null type in the value when the config uses the default option?

constdog=newFormControl('spot',{initialValueIsDefault:true});// dog has type FormControl<string>dog.reset();constwhichDog:string=dog.value;// spot

This should be doable with TS, respects the proposed API, and gets rid of the null type in the value for teams that want strict null checks. What would be the problem of that approach?

@dylhunn
Comment options

@samuelfernandez

would it be possible to remove the null type in the value when the config uses the default option?

Yes! This is our proposed design.

@indraraj26

This comment was marked as disruptive content.

Comment options

We need a way to represent the absence of a value for a form field of type number. For example, <p-inputNumber in PrimeNG accepts null and shows a blank input, or if it has a value and the value is backspaced out, returns null. Is this going to eventually be an issue? Should we all be migrating to using undefined? (I wish TypeScript would just anoint the Option type as idiomatic).

You must be logged in to vote
1 reply
@jrista
Comment options

I would say we should avoid monads (like Option) in TypeScript myself. That is an entirely different approach to programming. You really want an entire type system that supports monadic programming if you are going to start introducing that...and if you want that, then you should probably be using Haskell which has a composable type system with monads.

Regarding PrimeNG...any way you could support both null or undefined? What we now call "nullish"? It is easy enough, using== with null or undefined:

if(somethingThatCouldBeNullish==null)// The "weaker" double-equals here checks if the value is equal to null, or undefined, but nothing else// Do something if somethingThatCouldBeNullish IS nullish...

Using a nullish check, you could potentially refactor your codebase with a find-and-replace by looking for=== null and replacing it with== null... Just as one potential way of expanding support to undefined.

Comment options

@the Forms package currently behaves in a very unsafe way: controls reset to null, which can violate the expected value type.

Yes, unsafe and it may result in verbose serialization, because null will be presented in serialization by default. Currently I have to change all properties with null toundefined before sending a HttpClient request. It is better to reset of undefined, or at least Angular should provide such option.

You must be logged in to vote
0 replies
Sign up for freeto join this conversation on GitHub. Already have an account?Sign in to comment
Category
RFCs
Labels
None yet
31 participants
@dylhunn@bbarry@jrista@cexbrayat@e-oz@flensrocker@moniuch@kirjs@allout58@johncrim@alxhub@lasimon@kgar@the-ult@LayZeeDK@mbeckenbach@NetanelBasal@GinMitch@zijianhuang@Coly010@dannymcgeeand others

[8]ページ先頭

©2009-2025 Movatter.jp