Angular 14からReactive Formsの型が固くなるようです

Angular でフォームに対するユーザー入力を処理するには、テンプレート駆動型とリアクティブ型の2種類の実装方法があります。 Angular 14 では後者のリアクティブ型に機能強化が行われ、フォームの型がフレームワークに認識される様になります。今回はこの強化されたリアクティブ型を簡単に試してみます。


試した環境

Angular CLI: 14.0.0-next.9
Node: 14.18.2
Package Manager: npm 6.14.15
OS: win32 x64

Angular: 14.0.0-next.14
... animations, common, compiler, core, forms, platform-browser
... platform-browser-dynamic, router

Package                         Version
---------------------------------------------------------
@angular-devkit/architect       0.1400.0-next.9
@angular-devkit/build-angular   14.0.0-next.9
@angular-devkit/core            14.0.0-next.9
@angular-devkit/schematics      14.0.0-next.9
@angular/cli                    14.0.0-next.9
@angular/compiler-cli           14.0.0-next.9
@schematics/angular             14.0.0-next.9
rxjs                            7.5.5
typescript                      4.6.3

本記事の内容は Angular 14 開発版( 14.0.0-next.14 )で検証しています。
リリース版とは内容が異なる可能性がある点、予めお断りしておきます。

Strictly Typed Reactive Forms について

本記事の主題である型付けされた Reactive Forms は GitHub 上の Discussion にて、2021年12月から2022年3月まで RFC が実施されていました。フォームの形式が決定していてもこれまでの Reactive Forms は全て any 扱いになってしまい型安全の観点から全くよろしくない状態でしたので、 any に苦しめられてきた筆者にとって待望の新機能です。

github.com

この RFC ではあくまでも既存の機能に型付けするだけと定められ、明確に目指すべきポイント、実施しないポイントが宣言されています。

  • 目指すべきポイント

    • Angular Reactive Forms の開発体験を向上する
    • フォーム周辺のエコシステムを断片化しない
    • 既存の API を破壊しない範囲で可能な限り型安全にする
    • 型があるフォームと型が無いフォームを両立可能にする
    • この変更によって既存のアプリケーションを破壊しない
  • 実施しないポイント

    • テンプレート駆動型に変更は加えない
    • バリデーション機能などにも変更は加えない
    • ランタイムの挙動に変更は加えない

Strictly Typed Reactive Forms を試してみる

それでは実際に試してみましょう。なお、先にもお断りしたとおり開発版ですので、リリース版とは挙動が異なる可能性があります。また筆者の使用用途から FormGroup を対象としています。

フォーム作成と基本の型

まずは簡単にフォームを作成し、型情報を見てみましょう。

const form1 = new FormGroup({
  name: new FormControl(''),
  age: new FormControl(0)
});
// form1: FormGroup<{ name: FormControl<string | null>, age: FormControl<number | null> }>

form1.value
// form1.value: Partial<{ name: string | null, age: number | null }>

form1.getRawValue();
// form1.getRawValue(): { name: string | null, age: number | null }

form1.controls;
// form1.controls: { name: FormControl<string | null>, age: FormControl<number | null> }

FormGroup に Generics が追加され、フォームの構造を保持していることが分かります。 Generics の中身は { name: string, age: number } とフォームの中身の構造そのままになるのではなく、 FormControl<string | null> などとフォームの構造になっています。型推論以外で左辺の型を手作りするのはちょっと面倒臭そうです。
そして値が null 許容になっています。 Reactive Forms は値をリセットすると null に設定される仕様のため値が null 許容せざるを得ないと RFC に記載がありますので、その通りの挙動ですね。

また value の型が Partial になっています。これは FormControl の状態を disabled に変更するとその FormControl の値は value に含まれなくなるため、だそうです。 disabled の値まで含めて全てを取得する getRawValue() の方であれば Partial は付与されません。

ちなみに理由は分かりませんが、 FormGroup#contains を試すと WebStorm 2022.1 が死にます。。。

リセットと非 null の扱い

実際の開発ではフォームのデフォルト値が null 以外で決まっている場合の方が多いと思います。この場合ちょっと面倒ですが { initialValueIsDefault: true } とすることでリセット時の値が非 null に変化します。既存で FormControl をラップする機能などを開発している場合、注意が必要かもしれません。

const form1 = new FormGroup({
  name: new FormControl(''),
  age: new FormControl(0)
});
// form1: FormGroup<{ name: FormControl<string | null>, age: FormControl<number | null> }>

form1.value; // { name: '', age: 0 }
form1.reset();
form1.value; // { name: null, age: null }

const form2 = new FormGroup({
  name: new FormControl('', { initialValueIsDefault: true }),
  age: new FormControl(0, { initialValueIsDefault: true })
});
// form2: FormGroup<{ name: FormControl<string>, age: FormControl<number> }>

form2.value; // { name: '', age: 0 }
// form2.value: Partial<{ name: string, age: number }>
form2.reset();
form2.value; // { name: '', age: 0 }

FormBuilder

続いて FormBuilder を使用してフォームを作成する挙動を確認します。

const fb = new FormBuilder();
const form3 = fb.group({
  name: [''],
  age: [0]
});
// form3: FormGroup<{ name: FormControl<string | null>, age: FormControl<number | null> }>

FormBuilder でよく利用されるのはこのような Key-ArrayValue を渡すことです。この場合も綺麗に型が付いてくれることが分かります。

ただしこの方法では先の initialValueIsDefault を設定することはできません。特段事情が無ければ設定したい項目ですので、 Key-ArrayValue をやめて直接 FormControl を生成した方が良いでしょう。

const fb = new FormBuilder();
const form4 = fb.group({
  name: fb.control('', { initialValueIsDefault: true }),
  age: fb.control(0, { initialValueIsDefault: true }),
});
// form4: FormGroup<{ name: FormControl<string>, age: FormControl<number> }>

FormControl の追加と削除

最後に FormGroup に対するコントロールの追加と削除について、挙動を確認します。

残念ながら型付けされている FormGroup は後から柔軟に型を変更することができないため Immutable 扱いになりました。コントロールを追加、削除するためには型付けされていない FormGroup<any> とする必要があります。

const form5 = new FormGroup({
  name: new FormControl(''),
  age: new FormControl(0)
});
form5.addControl('address', new FormControl('')); // コンパイルエラー

const form6 = new FormGroup<any>({
  name: new FormControl(''),
  age: new FormControl(0)
});
form6.addControl('address', new FormControl('')); // 追加できる

型付けをした上で追加、削除を行うには、同じ型のコントロールしか設定できない弱点は残るものの、 FormGroup<Record<string, T> とすることで対応できます。ただ毎回この記述をするのは面倒ですので、 FormGroup<Record<string, T> と同等の機能である FormRecord<T> という新しいクラスが追加されました。筆者は Record と聞くと Immutable のイメージを抱くのですが、 Mutable です。

const form7 = new FormGroup<Record<string, FormControl<string | null>>>({
  prop1: new FormControl(''),
  prop2: new FormControl('')
});
form7.addControl('prop3', new FormControl('')); // 追加できる
form7.addControl('prop4', new FormControl(0)); // コンパイルエラー

const form8 = new FormRecord({
  prop1: new FormControl(''),
  prop2: new FormControl(''),
});
// form8: FormRecord<FormControl<string>>

form8.addControl('prop3', new FormControl('')); // 追加できる
form8.addControl('prop4', new FormControl(0)); // コンパイルエラー

なお本記事執筆時点では FormBuilder から FormRecord を作成することはできませんでした。

おわりに

とても簡単にですが、 Angular 14 で強化される予定の Reactive Forms の型について試してみました。ふわふわとしていた Reactive Forms が少し固くなり、大変歓迎できる新機能でした。ぜひ有効活用して、型安全な開発ライフを送りましょう!

執筆担当者プロフィール
中谷 大造

中谷 大造(日本ビジネスシステムズ株式会社)

情報システム部の中谷です。社内用スクラッチアプリの開発をしています。

担当記事一覧