Skip to content

[Complete] RFC: Strictly Typed Reactive Forms #44513

dylhunn announced in RFCs
[Complete] RFC: Strictly Typed Reactive Forms #44513
Dec 16, 2021 · 34 comments · 80 replies

RFC: Strictly Typed Reactive Forms

Author: @dylhunn
Contributors: @alxhub, @AndrewKushnir
Area: Angular Framework: Forms Package
Posted: December 16, 2021
Status: Complete
Related Issue: #13721

The goal of this RFC is to validate the design with the community, solicit feedback on open questions, and enable experimentation via a non-production-ready prototype included in this proposal.

Complete: This RFC is now complete. See a summary here.

Motivation

Consider the following forms schema representing a party, which allows users to enter details about their very own party:

type Party = {
  address: {
    house: number,
    street: string,
  },
  formal: boolean,
  foodOptions: Array<{
    food: string,
    price: number,
  }>
}

In the current version of Angular Forms, we can construct a corresponding form. Here’s such a form, populated with a default value. This default party is happening at 1234 Powell St, is not a formal event, and has no food options:

const partyForm = new FormGroup({
  address: new FormGroup({house: new FormControl(1234),
                          street: new FormControl('Powell St')}),
  formal: new FormControl(false),
  foodOptions: new FormArray([])
});

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

const partyDetails = partyForm.getRawValue(); // type `any`
const where = partyForm.get('address.street')!.value; // type `any`
partyForm.controls.formal.setValue(true); // param has type `any`

However, with typed forms, the types are highly specific and far more helpful:

const partyDetails = partyForm.getRawValue(); // a `Party` object
const where = partyForm.get('address.street')!.value; // type `string`
partyForm.controls.formal.setValue(true); // param has type `boolean`

These are much more useful types, and consumers that handle them incorrectly will get a compiler error (instead of a silent bug). For example, trying to do arithmetic on a value of a string control will now be an error: partyForm.get('address.street')!.value + 6.

This illustrates the purpose of typed forms: the API now reflects the structure of the form and its data. These benefits should prove especially useful for very complex or deeply nested forms.

Goals and Non-Goals

Goals

  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 a FormGroup. As you can see, the existing API has been extended in a backwards-compatible way: this code snippet will work with or without typed forms.

const cat = new FormGroup({
    name: new FormControl('bob'),
    lives: new FormControl(9),
});

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

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

Existing projects may not be 100% compatible with this stricter version of the reactive forms API at launch. To avoid build breakage, ng update will migrate existing calls to opt out of typed forms by providing an explicit any when constructing forms objects, thus aligning them with the current untyped semantics:

const cat = new FormGroup<any>({
    name: new FormControl<any>('bob'),
    lives: new FormControl<any>(9),
});

This <any> causes form APIs to function with the same semantics as untyped forms do today, allowing for an incremental migration path where applications and libraries can gradually improve type safety without fixing every type error at once.

In practice, we will add a type alias for any (e.g. AnyForUntypedForms) to attach some documentation to this particular usage and allow it to be easily recognized in application code.

Nullable Controls and Reset

Careful observers may note that null is showing up in the FormControl types above. This is because form models can be .reset() at any time, and the value of a reset() control is by default null:

const dog = new FormControl('spot'); // dog has type FormControl<string|null>
dog.reset();
const whichDog = dog.value; // null

This behavior is built into the forms runtime, and so the typed forms API infers nullable controls by default. However, this can make value types more inconvenient to work with. To improve the ergonomics, we're adding the ability for FormControls to be reset to a default value instead of null:

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

This gives you a choice – we’ll provide as much type safety as possible for old uses of FormControl, or you can provide a default value to get null-safety as well.

FormGroup Types

A FormGroup infers a type based on its inner controls. Recall our cat type from above:

const cat = new FormGroup<{
    name: FormControl<string>,
    lives: FormControl<number>,
}>(...);

In other words, a FormGroup's generic type is an interface that describes the types of each of its inner controls.

This may seem surprising, as one might imagine this type should describe the values instead:

interface Cat {
  name: string;
  lives: number;
}

const cat = new FormGroup<Cat>({
  name: new FormControl('spot, …),,
  lives: new FormControl(9, …),
});

However, we want to strongly type not just FormGroup.value, but FormGroup.controls. That is, the type of cat.controls.name should be the actual type of the name control, and not a plain AbstractControl type. This is only possible if the type of cat is built on the control types that it contains, not the value types of those controls.

Disabled Controls

The value property of a FormGroup is an object that contains the values of each constituent control, with one important difference: the value key for every control is optional. That is, the type of cat.value in the example above looks like the interface:

interface CatValue {
  name?: string;
  lives?: number;
}

This may seem surprising - any given key on the value object may not be present (and thus undefined if read). This happens because of the way disabled controls work in a FormGroup. When a control in a group is disabled, its value is not included in the value object:

// Disabling the `lives` key removes it from the group's value!
cat.controls.lives.disable();

console.log(cat.value.lives); // prints 'undefined'

If you want a value object for the group that includes the values for disabled controls, use the .rawValue() method instead.

The get Method

AbstractControl provides a get method for accessing descendant controls by name:

const g = new FormGroup({
    'a': new FormControl('foo'),
    'b': new FormGroup({'c': new FormControl('bar')})
  });
const val = g.get('b.c')!.value; // 'bar', has type string|null

We have implemented strong types for this method using template literal types. As long as a constant string literal is provided as the argument, we will tokenize it and extract the type of the requested control. If the argument is not a literal (e.g. it’s a string variable), the return type will be any.

Adding and Removing Controls

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

interface CatGroup {
  name: FormControl<string|null>,
  lives?: FormControl<number|null>,
}

const cat = new FormGroup<CatGroup>({
    name: new FormControl('bob'),
    lives: new FormControl(9),
  });
cat.removeControl('lives');

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

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

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

FormBuilder

In addition to typing the model classes, we have also added types to FormBuilder. Each builder method takes a type parameter, which will typically be inferred. That parameter works in the same manner as if the control had been constructed directly.

There are a number of ways to provide values to FormBuilder. All of these methods have been strongly typed:

const b = new FormBuilder();
const a = b.array([
  // A raw value
  'one',
  // A ControlConfig tuple
  ['two', someSyncValidator, someAsyncValidator],
  // A boxed FormState
  {value: 'three', disabled: false},
  // A control
  b.control('four'),
]);

// ['one', 'two', 'three', 'four']
const counting = a.value; // string[]

As you can see, you can provide a raw value, a control, or a boxed value, and the type will be inferred.

Usages of FormBuilder will have <any> or <any[]> inserted on pre-existing method calls, to preserve backwards compatibility.

Limitations

Control Bindings

When a FormControl is bound in a template, Angular's template type checking engine will not be able to assert that the value produced by the underlying control (described by its ControlValueAccessor) is of the same type as the FormControl. That is, the following:

const name = new FormControl('name'); // inferred type is FormControl<string|null>

<!-- error: string-valued FormControl bound to numeric-valued DOM control -->
<input type="range" [formControl]="name">

will result in name.value returning numeric values from the <input type="range">, despite being typed as FormControl<string|null>.

This is a limitation of the current template type-checking mechanism, due to the fact that the FormControlDirective which binds the control does not have access to the type of the ControlValueAccessor which describes the DOM control - each directive type is independent of any other directives on a given element. We have a few ideas on how to remove this restriction, but feel there is significant value in delivering stronger typings for forms even without this checking in place.

Template Driven Forms

The above restriction also applies to NgModel and template driven forms, which is why we've focused our efforts on reactive forms alone.

Because reactive form models are created in TypeScript code, there's a natural syntax for explicitly declaring their types if necessary. No such syntax exists in Angular's template language, further complicating any potential typings for template driven forms.

Try the Prototype

There is a prototype PR with an implementation. Below, we provide two methods for trying it out. This is a draft implementation, with missing features and non-final design aspects.

To try it on StackBlitz:

  1. Go to the demo StackBlitz project.
  2. Wait for all dependencies to be fetched and installed.
  3. Run ng serve.
  4. Edit profile.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 with npm 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 editing src/app/profile/profile.component.ts

To try it with your app:

  1. Ensure your app is on Angular 13.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 or yarn
  5. Update your app to the experimental next release: ng update @angular/core --next. Your package.json should now show that all angular packages are using the 13.2.0-next.2 version or higher.
  6. Open package.json in your project’s root directory. Find @angular/forms, and replace ~13.2.0-next.x with https://1106843-24195339-gh.circle-artifacts.com/0/angular/forms-pr43834-8e5ba4f698.tgz.
  7. Install the new dependencies (npm i or yarn, 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, and anys should have been inserted at all forms call sites. You can remove these anys to use the new types.

Questions for Discussion

In addition to general feedback, we would like to collect feedback on the following specific questions:

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

In the current design, FormArrays are homogeneous - every control in a FormArray is of the same type. However, TypeScript itself supports arrays where each element has its own type - known as a tuple type.

For example, the type [string, number] describes an array which must have at least 2 elements, where the first element is a string and the second is a number.

Our proposed design for FormArray does not support such cases (instead, FormArray<any> could be used, falling back to untyped semantics).

We are interested in any cases where a tuple-typed compound control would provide value.

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

In the current design, a typed FormGroup requires that all possible control keys are known statically. In some applications, FormGroups are used as maps, with a set of controls with dynamic keys that are added at runtime. For these cases, we currently recommend falling back to untyped form semantics using FormGroup<any>.

An alternative would be to provide an explicit FormGroup analogue that supports a dynamic mapping of controls. The tradeoff would likely be that all controls present in the grouping would have the same value type. Essentially, it would behave as the forms version of a Map<string, T>.

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

3. Is the current nullability of FormControls useful?

The original forms API allowed for initialization at construction to a specific value. However, controls would always use null as a default value to which they would revert when reset() - this means that all controls would necessarily be nullable.

For typed forms, we are introducing a configuration option to use the initial value as the default value instead, allowing for non-nullable controls.

Our long term plan is to remove the null reset behavior entirely, and always use the initial value as the default/reset value. To do this, in the future we will make initialValueIsDefault: true the default behavior, and eventually deprecate and remove the flag entirely.

For those cases where a truly independent initial value is required, the value can be changed via setValue immediately following the control's construction.

We would be interested in any use cases where this change in default value behavior would be problematic or burdensome, and where the current reset-to-null behavior is important.

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

As discussed above, binding to controls via directives (such as formControl and formControlName) is not type-safe. This can be improved in a future release, by adding warnings when a control is bound with an incompatible type. Is this shortcoming severe enough that we should delay any typings until it can be solved?

5. Does the prototype migration correctly handle existing projects?

The prototype shown above includes a migration to add <any> to existing forms usages. We would be especially interested if any cases are discovered where this migration does not apply correctly or does not insulate existing code from the effects of adding types to forms.

Replies

34 comments
·
80 replies

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.

5 replies
@dylhunn

dylhunn Dec 16, 2021
Collaborator Author

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

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?

@e-oz

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.

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 have initialValueAsDefault flag set to true 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 add initialValueAsDefault: false to the consumers' code.

Please do not repeat the ViewChild/ContentChild static 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

2 replies
@alxhub

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

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

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.

0 replies

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

0 replies

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!

5 replies
@dylhunn

dylhunn Dec 16, 2021
Collaborator Author

It's a bit confusing to allow an initialValueIsDefault property on a FormGroup. Consider the following pedantic example:

const nullableDog = new FormControl<string|null> = ('spot');
const nonNullableCat = new FormControl<string> = ('tabby', {initialValueIsDefault: true});
const animals = new FormGroup({
  cat: nonNullableCat,
  dog: nullableDog,
}, /* hypothetical property on a group */ {initialValueIsDefault: true});

What happens here? The two FormControls 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 your FormControls. 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

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 the reset() problem - reset() on a FormControl<string> will set it to a null value in spite of its strongly typed nature. A similar problem exists with FormGroup.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 of FormControls with options set is challenging. A helper method could make this simpler:

function makeFormControl<T>(value: T): FormControl<T> {
  return new FormControl(value, {initialValueIsDefault: true});
}

const fg = new FormGroup({
  alpha: makeFormControl('a'),
  beta: makeFormControl('b'),
  ...
});
@fatihdogmus

@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 like new 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 the FormControl as string it becomes nullable so I think this might confuse people. People wouldn't expect a FormControl that they typed as string and not as string | 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 :)

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

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

type MyFormType = {
  age: number;
}

const formGroup = new FormGroup<MyFormType>({
  age: new FormControl(0),
});

###

<input type="number" [formControlName]="age" />

When we perform formGroup.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 from HTMLInputElement.value), but TS will let you interact with it as a number, creating erroneous behaviour.

3 replies
@fatihdogmus

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 since new FormControl returns nullable types):

type MyFormType = {
  age: FormControl<number | null>;
}

const formGroup = new FormGroup<MyFormType>({
  age: new FormControl(0),
});

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

<form [formGroup]="formGroup">
  <input type="number" formControlName="age" />
  <button type="button" (click)="click()">Hebele</button>
</form>
type MyFormType = {
  age: FormControl<number | null>;
};
@Component({
  selector: 'profile',
  templateUrl: './profile.component.html',
  styleUrls: ['./profile.component.css'],
})
export class ProfileComponent {
  formGroup = new FormGroup<MyFormType>({
    age: new FormControl(0),
  });

  click() {
    //prints 'true'
    console.log(typeof this.formGroup.get('age')?.value === 'number');
  }
}
@dylhunn

dylhunn Dec 16, 2021
Collaborator Author

@fatihdogmus is correct. Just one addition: the null is optional, you can get rid of it by specifying initialValueIsDefault on the FormControl.

@Coly010

This is great to know! That’s awesome 🎉

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
3 replies
@the-ult
  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 provide opt out or extra settings for backwards compatibility.
This way you discourage the wrong(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

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

const b = new FormBuilder();
const form = b.record<Pledge & {
    [key: `comment-${keyof Pledge}`]?: string;
} & {
    [key: `allocation-${keyof Pledge}`]?: number;
}>()

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

@mbeckenbach

„ 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.

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:

const cat = new FormGroup<any>({
   name: new FormControl<any>('bob'),
   lives: new FormControl<any>(9),
});

It might be an idea to provide an extra option for the ng 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 update opt out " 😇
Or the other way around. Default to strong typed, but give ng 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
4 replies
@allout58

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

dylhunn Dec 17, 2021
Collaborator Author

The migration actually applies a special alias AnyForUntypedForms 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

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?

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 the reset() 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.g FormControl<string | null>(''))

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

interface Profile {
  firstName: string;
  lastName: string;
  address: {
    street: string;
    city: string;
  };
}

const profileForm = new FormGroup({
  firstName: new FormControl(''),
  lastName: new FormControl(''),
  address: new FormGroup({
    street: new FormControl(''),
    city: new FormControl('')
  })
});

I expose a ControlsOf 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 the disabled 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. A FormArray has the same type of controls.

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

// root.component
class Root {
  group = new FormGroup({})
}

// child.component
class Child {
  constructor(root: Root) {
    root.addControl('key', new FormControl(''))
  }
}

Moreover, dynamic forms libraries such as formly are worth looking at.

  • What about typing the ControlValueAccessor class? We can pass a generic ControlValueAccessor<string> that'll describe the expected type.
5 replies
@cexbrayat

When I was playing with earlier versions of this PR, I felt the need to have an InferControls type, which was basically a simpler version of ControlsOf 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

dylhunn Dec 18, 2021
Collaborator Author

@NetanelBasal Thanks for your insightful comment.

I expose a ControlsOf type in my library that provides this functionality

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.

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 current FormArray?

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 use getRawValue() and deprecate value, 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

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 use FormControl([]) in custom select elements. I'm forcing the consumer to explicitly pass it in my library to get around this problem:

interface User {
  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 a getValue() method that returns the same value as getRawValue() and deprecate both value and getRawValue(). Then, it could be removed in v14/15 as you decide.

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

Having an implementation of a strongly typed FormBuilder and FormControl and FormGroup (we don't use FormArray) we are using a value class with an input type like this:

type ValueOf<T> = number extends T ? number | undefined : boolean extends T ? boolean | undefined : T;
type TypeName<T> = T extends string  ? 'string' : T extends number ? 'number' : T extends boolean ? 'boolean' : never;
type TypeFromName<T> = T extends 'string' ? string : T extends 'number' ? number : T extends 'boolean' ? boolean : never;

function sanitizeBoolean(value: unknown): boolean | undefined {
  if(typeof value === 'boolean') return value;
  if(typeof value === 'string') {
    if (value === 'true') return true;
    if (value === 'false') return false;
  }
  return undefined;
}

function sanitizeNumber(value: unknown): number | undefined {
  // ...
}

function sanitizeString(value: unknown) {
  // ...
}

class FormControlTyped<T extends string|number|boolean, Name extends TypeName<T>> {
  constructor(private _value: ValueOf<T>, private readonly _type: Name) { }

  private typeGuard<TUnknown extends string|number|boolean, UnknownName extends TypeName<TUnknown>>(check: UnknownName): this is FormControlTyped<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.
    return this._type as unknown === check;
  }

  get Value(): ValueOf<T> {
    return this._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)
   */
  set Value(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) as any;
    }
    if (this.typeGuard('number')) {
      this._value = sanitizeNumber(this._value) as any;
    }
    if (this.typeGuard('boolean')) {
      this._value = sanitizeBoolean(this._value) as any;
    }
    throw new Error('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 for Value are what I am trying to get across.

1 reply
@alxhub

The problem with unknown 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 that FormControl & friends will always need a generic parameter specified (unless it can be inferred as with new FormControl(...)). So the anys hopefully won't be around forever.

It looks like FormArray is migrated to FormArray<AnyForUntypedForms[]>. Is [] necessary?

It feels redundant, as a FormArray returns an Array. Typing it FormArray<Something> instead of FormArray<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.

2 replies
@dylhunn

dylhunn Dec 18, 2021
Collaborator Author

We've been thinking about this quite a bit.

Pros of using FormArray<ElemType[]>:

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

Pros of using FormArray<ElemType>:

  1. Looks like the builtin Array<T>
  2. More consistent with FormControl and FormGroup
  3. More consistent migration

If we don't implement FormTuple 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

I vote for FormArray<ElemType>. That is much clearer.

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

2 replies
@dylhunn

dylhunn Dec 18, 2021
Collaborator Author

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

@alxhub

Yep - likely we need to look for dynamic import() as well as static import ... from statements.

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 example here is a generic ControlValueAccessor interface extension that I'm using in a project currently. In the past I've done similar things for FormGroup, FormControl, AbstractControl, etc., and @ngneat has a library that does this as well. With the current Angular types, anything defined as any 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'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?

3 replies
@dylhunn

dylhunn Dec 18, 2021
Collaborator Author

Regarding extending Forms classes:

Note that we are providing a default type T = any for all these classes. Any library that declares MyFormControl extends FormControl is implicitly extending FormControl<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.

Regarding ControlValueAccessor

It's becoming more and more clear that we need to type ControlValueAccessor as well. This will probably be a simple <T> on the interface, which will beused in writeValue and the onChange callbaek.

@Harpush

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

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 of any that we're adding to FormControl, etc, so that they will interact using the previous untyped behavior. That is:

class MyFormControl<T> extends FormControl {
  ...
}

implicitly extends FormControl<any>, so the base class behaves as an untyped FormControl.

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

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.

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

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

Something like this:

const dog = new FormControl('spot'); // dog has type FormControl<string>
this.getValueFrombackend.subscribe(value => dog.setValue('beSpot', {initialValueIsDefault: true}));
// ...few moments latter...
dog.reset();
const whichDog = dog.value; // beSpot
4 replies
@dylhunn

dylhunn Dec 19, 2021
Collaborator Author

What would happen if you call reset before the getValueFrombackend event fires? And what would the type of dog be?

@LendsMan

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

const dog = new FormControl('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}); // beSpot
     dog.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

dylhunn Dec 20, 2021
Collaborator Author

In the example you gave, the type of dog.value is string, but the value is null. That means the type would be lying. We definitely want to avoid that.

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.

const val = 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 using get 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 has drawbacks:

  • 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 rule no-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 using get we can safely 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 recommended eslint rules, a vital part of the ecosystem of wide use.

0 replies

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 use rawValue(), but being honest almost everyone uses value.

Proposal

  • Use a similar approach as described here [Complete] RFC: Strictly Typed Reactive Forms #44513 (comment) for the reset functionality. Let the model drive the decision of what actions can be made
  • Disable the disabled 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
3 replies
@bbarry

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 a FormControl<number> that is used with a custom input that happens to use a ControlValueAccessor<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 a number but the actual value will be a string.

@jaschwartzkopf

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

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

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

What if address becomes a fieldset and gets disabled by UI, the values of house and street will be skipped from value (will only remain available in getRawValue()), so the type of the entire form will become Partial<Party>. Is that correct?

  1. Control value type vs HTML

Will the html control be of type number or text?

<input formControlName="age">

age = new FormControl<number>()

Which type will prevail here?:

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

age = new FormControl<string>()

2 replies
@dylhunn

dylhunn Jan 13, 2022
Collaborator Author

What if address becomes a fieldset and gets disabled by UI, the values of house and street will be skipped from value (will only remain available in getRawValue()),

Yes, that's right.

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

The type of .value is always Partial<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 the Control 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 type string would prevail, but would not necessarily be correct if the bound input is inserting numeric values.

@moniuch

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

Migration to any

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 that doesn't add any type parameters could be included? Or a different but optional migration to remove the any type parameter added by the primary migration?

3 replies
@dylhunn

dylhunn Jan 14, 2022
Collaborator Author

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

@LayZeeDK

Sounds like something that could be automated 😉

@mbeckenbach

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

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

1 reply
@dylhunn

dylhunn Jan 18, 2022
Collaborator Author

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

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 set null

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

2 replies
@dylhunn

dylhunn Jan 18, 2022
Collaborator Author

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

dylhunn Jan 18, 2022
Collaborator Author

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

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

0 replies

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.

5 replies
@mbeckenbach

@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

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

@mbeckenbach

I am not sure if I get your point @flensrocker

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-typed ControlValueAccessor and ValidatorFn/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 the AnyForUntypedForms 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 the FormRecord<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 be undefined
  • FormArray: Homogenous array (use any or unions for heterogenous types)
  • FormRecord: Dynamic list with typed key and homogenous values (also use any or unions for heterogenous types)

RE FormRecord, 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 of reset() is unintuitive and causes typing bugs. Resetting to an initial or default value would be preferable.

The disabled formcontrol behavior causing undefined 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.

2 replies
@mbeckenbach

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

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 of formControl.value to include null and undefined, 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 of null and undefined for other purposes within the formControl.value type.

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

Alternatively, I could see value in adding formControl.disabledValue (default undefined for backward compatibility) in addition to formControl.defaultValue (default null for backward compatibility). That way, for example, the type of formControl.value could be string iff defaultValue and disabledValue are both string.

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.

2 replies
@dylhunn

dylhunn Jan 18, 2022
Collaborator Author

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

@mbeckenbach

Oh nice. I never noticed this. Thanks!

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.
0 replies

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 to expand types. With TypeScript strict mode, the use of null adds another factor to a type. This can cause null 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 the AsyncPipe, which forces the developer to add | null to ALL @Input()s on their child components in order to be compatible with async:

#43727

For some background, I prefer not to use null as a matter of course. There are nuanced differences between undefined and null that have given me reason to prefer undefined over null, 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 using undefined until such time as a null 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() whereas null properties DO, and this can lead to leaner wire-level data when undefined is used instead of null unless you actually want/need to deal with and store null 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. Using undefined 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:

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

The types listed here are string|null. This means that, if a codebase does not otherwise "normally" use null 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, where optional properties (which are undefined in nature, NOT null!!!) would commonly be used:

interface MyModel {
  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:

interface MyModel {
  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 is infectious and has to grow throughout the codebase, because the underlying framework is ADDING potentially unwanted nullable type expansions due to the currently proposed typed forms design. As with my AsyncPipe 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 prefer undefined 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 be T | 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.

2 replies
@the-ult

Same here. We try to use undefined instead of null and use the unicorn/no-null rule to help us with that.

@dylhunn

dylhunn Jan 28, 2022
Collaborator Author

As discussed below, we are forced into null because that's the current behavior of reset, but we are planning on fixing this (by removing the nullability altogether).

dylhunn
Jan 25, 2022
Collaborator Author

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 where FormRecord (but not FormTuple) 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!

14 replies
@jrista

Could you expound upon the "Our approach to nullability has tradeoffs, but seems to be an acceptable non-breaking solution." comment? What, in particular, would break if nulls were dropped?

With strict typing, this tradeoff is very non-trivial, and can have a huge impact across entire code bases...

@dylhunn

dylhunn Jan 27, 2022
Collaborator Author

@jrista Naively, FormControl types must always include null, because it is possible to reset them:

const fc = new FormControl(4); // type FormControl<number|null>
fc.reset();
console.log(fc.value); // null

This really sucks, since we want to make FormControl null-safe. However, to truly fix this issue, we will need to change the behavior of reset, which will involve deprecations across multiple major versions.

In the interim, you'll be able to do this:

const fc = new FormControl(4, {initialValueIsDefault: true}); // type FormControl<number>
fc.reset();
console.log(fc.value); // 4

In a future version, after a scheduled deprecation and migration, we can change the default behavior of reset:

const fc = new FormControl(4); // type FormControl<number>
fc.reset();
console.log(fc.value); // 4

It would be really nice to skip directly to the final state! However, that's just not possible to do in a single version without breaking a ton of apps.

@johncrim

@dylhunn : I know the RFC is closed, but just in case you didn't see my comment above. What do you think about:

const fc = new FormControl(4, { defaultValue: 0, disabledValue: 0 }); // type FormControl<number>
fc.reset();
console.log(fc.value); // 0

IMO this is conceptually simpler (always good), and provides some flexibility in that the initial value doesn't have to be the same as the default value.

The disabledValue is there to allow avoiding undefined checks when the formcontrol is disabled. There's a case for delaying that, but I think it's good to have in the eventual picture for consistency.

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).

1 reply
@jrista

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.

@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 to undefined before sending a HttpClient request. It is better to reset of undefined, or at least Angular should provide such option.

0 replies
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Category
RFCs
Labels
None yet