DEV Community

Jrubzjeknf
Jrubzjeknf

Posted on • Edited on

Making typed forms a bit more consistent

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
// }
Enter fullscreen mode Exit fullscreen mode

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 potentially undefined, 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());
Enter fullscreen mode Exit fullscreen mode

Good luck!

Top comments (0)