Skip to content

Commit 9f6fa5b

Browse files
dylhunnAndrewKushnir
authored andcommitted
fix(forms): Prevent FormBuilder from distributing unions to control types. (#45942)
Previously, using `FormBuilder` with a union type would produce unions of *controls*: ``` // `foo` has type `FormControl<string>|FormControl<number>`. const c = fb.nonNullable.group({foo: 'bar' as string | number}); ``` This actually works in many cases, due to how extraordinarily powerful Typescript's distributive types are (e.g. `value` still has type `string|number`), but it is subtly incorrect. Here is a code example that exposes the reason the inference is incorrect. It exploits the fact that Typescript will not "un-distribute" a type, producing an obviously spurious error: ``` // fc gets an inferred distributive union type `FormControl<string> | FormControl<number>` let fc = c.controls.foo; // Error: Type 'FormControl<string | number>' is not assignable to type 'FormControl<string> | FormControl<number>'. fc = new FormControl<string|number>('', {initialValueIsDefault: true}); ``` Instead, we want the union to apply to the *values*: ``` // `foo` should have type `FormControl<string|number>`. const c = fb.nonNullable.group({foo: 'bar' as string | number}); ``` Essentially, we want to prevent Typescript from distributing the type. [As specified in the handbook](https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types): > Typically, distributivity is the desired behavior. To avoid that behavior, you can surround each side of the extends keyword with square brackets. This PR applies this suggestion to `FormBuilder`'s type inference. Fixes #45912. PR Close #45942
1 parent 7dd6450 commit 9f6fa5b

2 files changed

Lines changed: 22 additions & 7 deletions

File tree

packages/forms/src/form_builder.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,19 @@ export type ControlConfig<T> = [T|FormControlState<T>, (ValidatorFn|(ValidatorFn
4848
* The flag N, if not never, makes the resulting `FormControl` have N in its type.
4949
*/
5050
export type ɵElement<T, N extends null> =
51-
T extends FormControl<infer U> ? FormControl<U> :
52-
T extends FormGroup<infer U> ? FormGroup<U> :
53-
T extends FormArray<infer U> ? FormArray<U> :
54-
T extends AbstractControl<infer U> ? AbstractControl<U> :
55-
T extends FormControlState<infer U> ? FormControl<U|N> :
56-
T extends ControlConfig<infer U> ? FormControl<U|N> :
51+
// The `extends` checks are wrapped in arrays in order to prevent TypeScript from applying type unions
52+
// through the distributive conditional type. This is the officially recommended solution:
53+
// https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types
54+
[T] extends [FormControl<infer U>] ? FormControl<U> :
55+
[T] extends [FormGroup<infer U>] ? FormGroup<U> :
56+
[T] extends [FormArray<infer U>] ? FormArray<U> :
57+
[T] extends [AbstractControl<infer U>] ? AbstractControl<U> :
58+
[T] extends [FormControlState<infer U>] ? FormControl<U|N> :
59+
[T] extends [ControlConfig<infer U>] ? FormControl<U|N> :
5760
// ControlConfig can be too much for the compiler to infer in the wrapped case. This is
5861
// not surprising, since it's practically death-by-polymorphism (e.g. the optional validators
5962
// members that might be arrays). Watch for ControlConfigs that might fall through.
60-
T extends Array<infer U|ValidatorFn|ValidatorFn[]|AsyncValidatorFn|AsyncValidatorFn[]> ? FormControl<U|N> :
63+
[T] extends [Array<infer U|ValidatorFn|ValidatorFn[]|AsyncValidatorFn|AsyncValidatorFn[]>] ? FormControl<U|N> :
6164
// Fallthough case: T is not a container type; use it directly as a value.
6265
FormControl<T|N>;
6366

packages/forms/test/typed_integration_spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1366,6 +1366,18 @@ describe('Typed Class', () => {
13661366
expect(c.value).toEqual({foo: 'bar'});
13671367
});
13681368

1369+
it('without distributing union types', () => {
1370+
const c = fb.group({foo: 'bar' as string | number});
1371+
{
1372+
type ControlsType = {foo: FormControl<string|number>};
1373+
let t: ControlsType = c.controls;
1374+
let t1 = c.controls;
1375+
t1 = null as unknown as ControlsType;
1376+
}
1377+
let fc = c.controls.foo;
1378+
fc = new FormControl<string|number>('', {initialValueIsDefault: true});
1379+
});
1380+
13691381
describe('from objects with FormControls', () => {
13701382
it('from objects with builder FormGroups', () => {
13711383
const c = fb.group({foo: fb.group({baz: 'bar'})});

0 commit comments

Comments
 (0)