はじめに

今年に入りチーム編成が変わり、バックエンドエンジニアからフロントエンドやインフラも触ることになりました。 詳しくはこちらをご覧ください。

その中でAngular v18環境でのフォーム実装に関わる機会があり、特にリアクティブフォームの「型の付け方」や「構成パターン」で混乱があったので、 本記事ではTyped Forms(型付きフォーム)におけるフォームオブジェクトの生成、型推論、および nonNullable の扱いなどを体系的に整理し、より型が安全なフォーム実装に必要な観点をまとめます。

Angularのフォーム構成の方法

Angularにはフォームを作成するための2つの主要な構成方法があります。

公式ドキュメントを見ていただければわかりますがデータフローや入力フィールド数の規模感の違いなどがあり、 フォームの構造やロジックの定義箇所、型推論の取り扱いが選定時に特に重要になると感じました。

  • リアクティブフォーム:フォーム構造・ロジックをTypeScript側で定義する。
  • テンプレート駆動フォーム:フォーム構造・ロジックをHTMLテンプレート側に記述する。

本記事では、Typed Forms(型付きフォーム)を活かせるリアクティブフォームに焦点を当てて解説します。

リアクティブフォームの構成要素

フォームの実装をする上で良いとされる構成パターンはよく見かけますがなぜいいのかがわからない状態だったので、 以下の3つの観点に分けて順に理解していくとわかりやすいと感じたので構成方法や型指定方法の違いによって起きうる危険性も含めて解説していきます。

  1. フォームオブジェクトの作成方法
  2. フォームの型の指定方法
  3. オプション設定

フォームオブジェクトの生成方法

オブジェクトの作成には、主に以下の方法があります。

  • フォームの基盤となるクラスをnew(インスタンス化)して作成する方法(以下、「ローレベルAPI」)
  • Angularが提供するユーティリティサービスのFormBuilderを使用して作成する方法

以下にそれぞれのサンプルコードを示し違いを説明します。

他にも深いところで違いがあるんだとは思いますが、個人的にはDIでのテストのしやすさや可読性の点からFormBuilderの方が好みでした。

ローレベルAPIのサンプルコード

フォームの基盤クラスであるFormGroupFormControlを明示的にインスタンス化してフォームを作成します。

export class UserFormComponent {
    userForm = new FormGroup({
        name: new FormControl(''),
    });
    ...
}

FormBuilderのサンプルコード

FormBuilderを使用してフォームを作成します。

export class UserFormComponent {
    constructor(private fb: FormBuilder) {}
    userForm = this.fb.group({
        name: this.fb.control(''),
    });
    ...
}

フォームの型指定方法

上記で作成したフォームに型を指定する方法として、v14以降に導入されたTyped Forms(型付きフォーム)を使用することでより型安全なフォームの実装が可能になります。

Typed Forms(型付きフォーム)を使っていて最も混乱したのが、開発者が指定した型と型推論の違いでした。 型パラメータやNonNullableFormBuilderを使うことでより開発者が明示的に扱う型を宣言することができ、より型安全なフォームの実装が可能になります。ですが、フォームのFormControlがnullを許容するかどうかによって意図しない型推論が行われることがあるので注意が必要です。

以下に分けて解説します。

  • nullを許容しない場合
  • nullを意図して許容する場合
  • nullを意図せず許容してしまっている場合

nullを許容しない場合

nullを許容しない場合は、NonNullableFormBuilderと型パラメータ<T>使うことでシンプルにより型安全なフォームを作成できます。

userForm = this.fb.group({
    name: this.fb.control<string>(""),
});

constructor(private fb: NonNullableFormBuilder) {}
FormGroup<{   
    name: FormControl<string>
}>

明示した通り<string>の型パラメータを指定することで、nullを許容しない型(FormControl<string>)として推論されます。

nullを許容する場合

nullを許容する場合は、以下のようにFormBuilderと型パラメータ<T>を使ってフォームを作成できます。

userForm: FormGroup<{
    name: FormControl<string | null>;
}> = this.fb.group({
    name: this.fb.control<string | null>(""),
});

constructor(private fb: FormBuilder) {}
FormGroup<{
    name: FormControl<string | null>
}>

これは記述通りの型推論になっており、FormControl<string | null>のようにnullを許容する型として推論されます。

nullを意図せず許容してしまっている場合

nullを意図せず許容してしまっている場合は、以下のようにFormBuilderで明示的に<string>の型パラメータを与えているように見えて、内部的なFormControlの仕様によってnullが許容されてしまいます。

userForm: FormGroup<{
    name: FormControl<string>;
}> = this.fb.group({
    name: this.fb.control<string>(""),
});

constructor(private fb: FormBuilder) {}
FormGroup<{
    name: FormControl<string | null>
}>

fb.control<string>で型パラメータを<string>で与えているのでnullは許容されていないように見えますが、AngulerのFormControlの仕様によって、nullが許容されてしまいます。

以下はFormControlの該当するソースコードです。

control<T>(
    formState: T | FormControlState<T>,
    validatorOrOpts?: ValidatorFn | ValidatorFn[] | FormControlOptions | null,
    asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null
): FormControl<T | null>;

control<T><T>を型パラメータとして与えても返り値は<T | null>になっていることがわかります。 上記のように型パラメータだけではnullが許容されている状態となりコンパイルエラーやバグの原因になります。

これを回避するためにオプション設定を理解しておくことが重要です。

オプション設定

FormControl()fb.control() では、第2引数として構成オプションを渡すことができます。 nullを許容しないようにするためには、nonNullable: trueオプションを指定します。

nullを意図せず許容してしまっている場合にオプションを追記すると以下のようになります。

userForm = this.fb.group<{
    name: FormControl<string>;
}>({
    name: this.fb.control<string>("", { nonNullable: true }),
});

constructor(private fb: FormBuilder) {
    this.userForm = this.fb.group({
        name: this.fb.control<string>("", { nonNullable: true }),
    });
}
FormGroup<{   
    name: FormControl<string> 
}>

nonNullable: trueを指定することで、返り値のFormControl<T|null>の型がFormControl<T>に変わり、nullを許容しないことで型安全性を高めることができます。

以下はnunNullable: trueを設定した場合のソースコードです。 先ほどのnonNullableのオプション設定のないソースコードと比べると、返り値がFormControl<T|null>ではなくFormControl<T>になっていることがわかります。

control<T>(
    formState: T | FormControlState<T>,
    opts: FormControlOptions & {
        nonNullable: true;
    }
): FormControl<T>;

オプションを使うことでnullを許容しないようにすることはできますが、より型安全なフォームを実装したい場合はnullを許容しない場合でフォーム作成をすることで型安全性を高めることができます。

まとめ

リアクティブフォームは柔軟性が高いため設計を曖昧にしたまま進めると、型の不一致や型推論のnullによるバグが発生しやすくなる印象を受けました。 そのため最初にフォームオブジェクト+型パラメータ+オプション設定を実装の意図に合わせて組むことが、型安全性とテスト容易性を高めるために重要だと感じました。

本記事では触れませんでしたが、interface で値の型を明示したり、FormBuilder.nonNullable.group() を使ったりすることでも型の安全性を担保できます。 またリアクティブフォームに触れる中で、RxJSによる非同期処理やストリームの考え方(Observablepipe()を使ったオペレーター連結など)も非常に新鮮に感じたのでまた紹介できればと思います。

おまけ

v13以前での型の戻り値

ブログを書く中でわかったことですがv13以前ではFormGroupFormControlに対して型パラメータ<T>を与えることができず、戻り値はFormGroup<any>と返ってきていたようで完全に型安全にしたい場合は、ngx-typed-formsなどのライブラリを使う必要があったようです。 この問題はv14でTypedFormsが公式に導入されたことで解消され、今はFormGroup<T>FormControl<T>による型安全なフォームが標準機能として扱えるようになっています。 詳しくはこちらを参照してください。