Angular Typed Forms を便利に使うスニペット

2022/12/25に公開

こんにちは、Angular GDEのlacolacoです。Angularアドベントカレンダー 25日目の記事は、Angular Typed Forms を便利に使うスニペットです。
Angular v14で導入された Typed Forms の恩恵をみなさんも日々受けていると思いますが、普段の開発の中で私がよく使うものや、最近作ってみたらけっこうよさそうだったものをまとめてみました。

SimpleControlValueAccessor<T>

現在のTyped Forms では FormControl<T>FormGroup<T> などの Reactive FormsのフォームモデルAPIにジェネリクス型を指定することができますが、カスタムフォームコントロールを作るために使う ControlValueAccessor にはジェネリクス型が指定できません。

ControlValueAccessor インターフェースの実装はどのカスタムコントロールも似たりよったりの実装になりがちで、ボイラープレートのコードが多くなります。また、カスタムコントロールはアプリケーション特有のユースケースに対応することが多いですが、たいていの場合は単一の型の入出力に対応すれば十分です。

というわけで最近作ってみた SimpleControlValueAccessor<T> 抽象クラスを紹介します。

const noop = () => {};

@Directive()
export abstract class SimpleControlValueAccessor<T> implements ControlValueAccessor, OnDestroy {
  protected onChange: (_: T) => void = noop;
  protected onTouched: () => void = noop;

  protected readonly ngControl = inject(NgControl, { optional: true });
  private readonly onDestroy$ = new Subject<void>();
  protected readonly takeUntilDestroyed = <T>() => pipe<Observable<T>, Observable<T>>(takeUntil(this.onDestroy$));

  constructor() {
    if (this.ngControl) {
      this.ngControl.valueAccessor = this;
    }
  }

  abstract setDisabledState(isDisabled: boolean): void;

  abstract writeValue(value: T): void;

  ngOnDestroy(): void {
    this.onDestroy$.next();
    this.onDestroy$.complete();
  }

  registerOnChange(fn: (_: T) => void): void {
    this.onChange = fn;
  }
  registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }
}

だいたいのカスタムコントロールで共通になる registerOnChangeregisterOnTouched の実装を抽象クラスにまとめています。また、ngOnDestroySubject を発行して takeUntil で購読を解除するのに便利な takeUntilDestroyed パイプも用意しています。

この抽象クラスを継承して ControlValueAccessor を実装すると、次のようなコードになります。具象クラスでは writeValuesetDisabledState の実装だけを書けばよくなります。この例ではカスタムコントロールの内部で input 要素と FormControl を使っていますが、ngModel を使ってもいいですし、カスタムコントロールとしてどのようにユーザーとやり取りするかは自由です。

import { SimpleControlValueAccessor } from '../utitilites/forms';

@Component({
  selector: 'custom-input',
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule],
  template: `
    <input
      type="number"
      min="0"
      max="100"
      required
      [formControl]="formControl"
      (click)="onTouched()"
    />
    <div class="buttons">
      <button (click)="onTouched(); setValue(100)" [disabled]="formControl.disabled">MAX</button>
      <button (click)="onTouched(); setValue(0)" [disabled]="formControl.disabled">MIN</button>
    </div>
  `,
})
export class CustomInputComponent extends SimpleControlValueAccessor<number> {
  readonly formControl = new FormControl(0, { nonNullable: true });

  constructor() {
    super();
    // 内部FormControlの値が変わったら、親のFormControlに値を伝える
    this.formControl.valueChanges.pipe(this.takeUntilDestroyed()).subscribe((value) => {
      this.onChange(value);
    });
  }

  // 親のFormControlの値が変わったら、内部FormControlに値を伝える
  override writeValue(value: number): void {
    this.formControl.setValue(value, { emitEvent: false });
  }

  override setDisabledState(isDisabled: boolean): void {
    if (isDisabled) {
      this.formControl.disable();
    } else {
      this.formControl.enable();
    }
  }

  setValue(value: number): void {
    this.formControl.setValue(value);
  }
}

getValidValueChanges()

Typed Forms になってから、 AbstractControl<T>valueChanges プロパティも Observable<T> 型になって使いやすくなりました。このスニペットはあまりジェネリクスとは関係ないですが、フォームモデルが VALID なときだけ valueChanges を流す Observable を作る関数です。

export function getValidValueChanges<T>(control: AbstractControl<T>): Observable<T> {
  return combineLatest([control.valueChanges, control.statusChanges]).pipe(
    filter(([, status]) => status === 'VALID'),
    map(([value]) => value),
  );
}

よくある実装では valueChanges を購読したコールバック関数の中で control.valid を確認しますが、 valueChanges に値が流れるタイミングでは control.valid が更新されていないことがあるため、確実に VALID なときだけ値を取得したい場合には control.updateValueAndValidity() を呼び出す必要があります。
このスニペットでは statusChangesvalueChangescombineLatest しているため、statusChangesVALID になったタイミングで valueChanges が流れるようになっています。そのためコールバック関数の中で control.valid を確認する必要はなくなります。


getValidValueChanges(this.formControl).pipe(takeUntil(this.onDestroy$)).subscribe((value) => {
  // 常にフォームモデルが VALID なときだけ値が流れる
});

zod を使ったフォームモデルのバリデーション

zod はTypeScriptととても相性がいいバリデーションライブラリです。このスニペットは zod の API をカスタムバリデータ関数の中で使うものです。特定の型に対応したフォームコントロールとバリデータは、フォームコントロールの生成関数に隠蔽してしまうのが使いやすいです。

// スキーマ定義
import { z } from 'zod';
export const Age = z.number().int().positive().max(100).brand('Age');
export type Age = z.infer<typeof Age>;

// カスタムコントロール
export function createAgeControl(defaultValue: Age, opts: FormControlOptions = {}): FormControl<Age> {
  return new FormControl(defaultValue, {
    ...opts,
    validators: [
      ...(opts.validators ? (Array.isArray(opts.validators) ? opts.validators : [opts.validators]) : []),
      // カスタムバリデータ
      (control: AbstractControl) => {
        // zod のスキーマ定義から使ってバリデーションする
        const value = Age.safeParse(control.value);
        if (!value.success) {
          // zod のエラーコードでバリデーションエラーを返す
          return Object.fromEntries(value.error.issues.map((issue) => [issue.code, issue.message]));
        }
        return null;
      },
    ],
    nonNullable: true,
  });
}

もっと抽象化して zod で定義されたスキーマに汎用的に使えるようにジェネリクスを使うとこんな感じにもできます。

export function zodTypeValidator<T extends z.ZodType>(zodType: T): ValidatorFn {
  return (control: AbstractControl) => {
    const value = zodType.safeParse(control.value);
    if (!value.success) {
      return {
        type_error: value.error.message,
      };
    }
    return null;
  };
}

export function createZodTypeControl<T extends z.ZodType>(
  zodType: T,
  defaultValue: z.infer<T>,
  opts: FormControlOptions = {},
): FormControl<z.infer<T>> {
  return new FormControl(defaultValue, {
    ...opts,
    validators: [
      ...(opts.validators ? (Array.isArray(opts.validators) ? opts.validators : [opts.validators]) : []),
      zodTypeValidator(zodType),
    ],
    nonNullable: true,
  });
}

const ageFormControl: FormControl<Age> = createZodTypeControl(Age, Age.parse(20), {
    validators: [Validators.required],
});

値の詳細なバリデーションをオブジェクトのスキーマ定義に任せられるので、フォームモデル周辺のUIロジックから関心が減ってコンポーネントがすっきりします。

2023年もよろしくお願いします

来年も引き続きAngular GDEとして日本のAngularコミュニティをサポートしていきます。みんながAngularを使って開発する時間が少しでも楽しいものになるようにあれこれやっていきます。

コミュニティの盛り上がりというのは誰かひとりが頑張っても作れるものではなく、参加者ひとりひとりのちょっとした貢献の積み重ねですから(このアドベントカレンダーもそう!)、ぜひともコミュニティのみなさんと一緒に盛り上がりを作っていきたいです。来年もよろしくお願いします!

Discussion