Making typed forms a bit more consistent
Jrubzjeknf
Posted on July 19, 2022
Angular typed forms are pretty awesome. Strongly typing your forms bring a lot of benefits, but one issue is holding it back: allowing you to define a single interface from which you can create a typed form and infer the form value from it.
Consider the following code:
interface UserFormControls {
firstName: FormControl<string>;
lastName: FormControl<string>;
email: FormControl<string | null>;
age: FormControl<number | null>;
}
interface User {
firstName: string;
lastName: string;
email: string | null;
age: number | null;
}
function processUser(user: User): void {
// ...
}
const userForm = new FormGroup<UserFormControls>({
firstName: new FormControl('foo', { nonNullable: true }),
lastName: new FormControl('bar', { nonNullable: true }),
email: new FormControl('foo@bar.com', { nonNullable: true }),
age: new FormControl(null)
});
processUser(userForm.value); // This won't actually compile, keep reading
Ideally, you don't want to be forced to maintain two separate interfaces defining the same thing. The User interface can be inferred from the UserFormControls, so let's do that. We use two new types for this.
type FormValue<T extends AbstractControl> =
T extends AbstractControl<infer TValue, any>
? TValue
: never;
type FormRawValue<T extends AbstractControl> =
T extends AbstractControl<any, infer TRawValue>
? TRawValue
: never;
Let's see what happens when we apply these to our UserFormControls
.
interface UserFormControls {
firstName: FormControl<string>;
lastName: FormControl<string>;
email: FormControl<string | null>;
age: FormControl<number | null>;
}
type UserForm = FormGroup<UserFormControls>;
type User = FormValue<UserForm>;
// type User = {
// firstName?: string | undefined;
// lastName?: string | undefined;
// email?: string | null | undefined;
// age?: number | null | undefined;
// }
type UserRaw = FormRawValue<UserForm>;
// type UserRaw = {
// firstName: string;
// lastName: string;
// email: string | null;
// age: number | null;
// }
Note that the User
type now has all it's properties as optional. This is because controls can be disabled and those won't show up in the final form value. The raw value is typed exactly how we specified our User interface earlier. It is also why the processUser(userForm.value);
in the first code block won't compile.
Make your choice
Here you must make a choice:
- You can either use the
FormValue<..>
and deal with every property being potentiallyundefined
, or; - Use the
FormRawValue<..>
with care. As long as all the controls that can be disabled are marked as optional, your typing will be sound.
My recommendation would be the latter. In that case, we'll end up with the following solution:
type User = FormRawValue<UserForm>;
// type User = {
// firstName: string;
// lastName: string;
// email: string | null;
// age: number | null;
// }
// ...
function processUser(user: User): void {
// ...
}
processUser(userForm.value as User);
// or:
processUser(userForm.getRawValue());
Good luck!
Posted on July 19, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.