Angularで抽象クラスにフォーム定義を分離する型安全設計
Angularのリアクティブフォームに型を加えたTyped Formsを解説します。
ディレクトリ構成
フォーム定義を抽象クラスに切り出すことで、コンポーネントの責務を明確に分離しています。
profile-create/
├── profile-create.component.ts # コンポーネント
├── profile-create.component.html # テンプレート
└── profile-create.form.abstract.ts # フォーム定義(抽象クラス)
抽象クラスでフォーム定義
抽象クラスを使うことで、フォームの定義とコンポーネントのロジックを分離できます。
フォームの構造とバリデーション、テンプレートで使うゲッターを抽象クラスにまとめます。
import { inject } from '@angular/core';
import { FormControl, FormGroup, NonNullableFormBuilder, Validators } from '@angular/forms';
export type ProfileFormGroup = FormGroup<{
name: FormControl<string>;
email: FormControl<string>;
}>;
export abstract class ProfileCreateFormAbstract {
private fb = inject(NonNullableFormBuilder);
readonly form: ProfileFormGroup = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
});
get nameControl(): FormControl<string> {
return this.form.controls.name;
}
get emailControl(): FormControl<string> {
return this.form.controls.email;
}
}
Typed Forms
Typed Formsとは、リアクティブフォームを型安全に扱うための仕組みです。
Angular 14から導入され、FormGroup と FormControl に型を付けて定義できます。
// FormGroup を型付けして、フォームの構造を固定する
export type ProfileFormGroup = FormGroup<{
name: FormControl<string>;
email: FormControl<string>;
}>;
readonly form: ProfileFormGroup = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
});
これにより、存在しないフィールドへのアクセスがコンパイルエラーになります。
// 型あり:コンパイル時にエラーになる
form.controls.nmae; // Property 'nmae' does not exist
form.controls.name; // OK
NonNullableFormBuilder
NonNullableFormBuilder を使うと、フォームコントロールの値が null にならないことを保証できます。通常の FormBuilder では control.value が string | null 型になりますが、NonNullableFormBuilder では string 型になり、null チェックが不要になります。
// NonNullableFormBuilder を使用
private fb = inject(NonNullableFormBuilder);
readonly form: ProfileFormGroup = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
});
// → FormControl<string> (null にならない)
// 通常の FormBuilder を使用した場合
import { FormBuilder } from '@angular/forms';
private fb = inject(FormBuilder);
readonly form = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
});
// → FormControl<string | null> (null の可能性がある)
null を許容しないことで、フォームの値を扱う際に null チェックが不要になります。
// NonNullableFormBuilder の場合
const name = this.form.controls.name.value;
console.log(name.toUpperCase()); // OK(string 型)
// FormBuilder の場合
const name = this.form.controls.name.value;
console.log(name.toUpperCase()); // エラー(string | null 型)
console.log(name?.toUpperCase()); // null チェックが必要
ゲッターでコントロールを公開
nameControl、emailControl のゲッターを定義することで、
HTML テンプレートから簡潔にアクセスできます。
get nameControl(): FormControl<string> {
return this.form.controls.name;
}
form.controls.name ではなく nameControl と書けます。
コンポーネントでフォーム定義の抽象クラスを継承
コンポーネントは抽象クラスを継承し、画面固有のロジックだけを持ちます。
フォームの構造やバリデーションは抽象クラス側に集約されています。
import { Component } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { ProfileCreateFormAbstract } from './profile-create.form.abstract';
@Component({
selector: 'app-profile-create',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './profile-create.component.html',
styleUrl: './profile-create.component.css',
})
export class ProfileCreateComponent extends ProfileCreateFormAbstract {
onSubmit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
console.log('Form submitted:', this.form.getRawValue());
}
}
HTMLテンプレートで FormControl を紐付ける
[formControl] ディレクティブで input 要素と FormControl を接続します。
これにより、入力値の変更やバリデーションエラーが自動的に FormControl に反映されます。
<form (ngSubmit)="onSubmit()">
<input [formControl]="nameControl" />
@if (nameControl.touched && nameControl.hasError('required')) {
<div class="error">Name is required</div>
}
<input [formControl]="emailControl" />
@if (emailControl.touched && emailControl.hasError('required')) {
<div class="error">Email is required</div>
} @if (emailControl.touched && emailControl.hasError('email')) {
<div class="error">Please enter a valid email address</div>
}
<button type="submit">Create Profile</button>
</form>
この設計が向いているケース
本記事で紹介した「抽象クラス + Typed Forms」の構成は、
すべてのフォームに必須というわけではありません。
特に、以下のようなケースで効果を発揮します。
- 入力項目が多く、フォーム定義が肥大化しやすい場合
- フォームのロジック(バリデーションや初期値)を明確に分離したい場合
一方で、項目数が少ないシンプルなフォームでは、
コンポーネント内に直接定義した方が読みやすい場合もあります。
まとめ
Typed Formsによってフォームを型安全に扱える方法を解説しました。
Discussion