Marginalia

Angular: 配列クエリパラメータのためのInput Transforms

Angular v16で導入されたInput Transformsは、 @Input({ transform: transformFn }) というように関数を渡すことでインプットプロパティに値がセットされるときの変換処理を宣言できる。典型的なユースケースは、 <button disable> のようにHTML標準のブール値属性の挙動を模倣したディレクティブやコンポーネントを作成するときにブール値に変換する用途だろう。また、 <img width="16"> のように数値を受け取る属性も、HTML属性としての振る舞いを模倣するなら文字列から変換することになる。

Accepting data with input properties • Angular

import {Component, Input, booleanAttribute, numberAttribute} from '@angular/core';
@Component({...})
export class CustomSlider {
  @Input({transform: booleanAttribute}) disabled = false;
  @Input({transform: numberAttribute}) number = 0;
}

この機能と、同じくAngular v16で導入されたRouterのComponent Input Bindingを併用することで、配列型のデータをクエリパラメータに変換するユースケースが扱いやすくなる。

クエリパラメータ内の配列

配列型をクエリパラメータとして表現する形式にはさまざまなパターンがあるが、Routerの navigate() メソッドや RouterLink で配列型の値をクエリパラメータに指定すると、Angularは同じキーのパラメータを複数回繰り返す key=param1&key=param2 という形式に変換する。

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet, RouterLink],
  template: `
    <router-outlet />
    <ul>
      <li><a routerLink="" [queryParams]="{query: null}" >no query</a></li>
      <li><a routerLink="" [queryParams]="{query: 1}" >query=1</a></li>
      <li><a routerLink="" [queryParams]="{query: [1]}" >query=[1]</a></li>
      <li><a routerLink="" [queryParams]="{query: [1,2]}" >query=[1,2]</a></li>
    </ul>
  `,
})
export class App {}

const routes: Routes = [
  {
    path: '',
    component: Page,
  },
];

bootstrapApplication(App, {
  providers: [provideRouter(routes, withComponentInputBinding())],
});

クエリパラメータに配列型を書き込むのは簡単だが、逆にクエリパラメータから読み取るのは少し工夫が必要になる。なぜかというと、この形式では query=1 だけが存在する場合にそれがもともと配列であったかどうかという情報が失われるからだ。つまり、配列ではない値 { query: 1 } と長さ1の配列 { query: [1] } から出力されるクエリパラメータがどちらも同じ結果になってしまうのだ。

このことを念頭に入れておかないと、次のようなナイーブな実装はすぐに実行時エラーを投げるだろう。 Routerの withComponentInputBinding() オプションによって次の query インプットプロパティにはクエリパラメータの値がセットされるが、クエリパラメータに書き込むときに配列だったとしても長さが1であれば単なる文字列になってしまい、 query.join() メソッドは文字列に存在しないためエラーになる。

@Component({
  standalone: true,
  imports: [JsonPipe],
  template: `
  <div>query={{ query.join(', ') }}</div>
  `,
})
export class Page {
  @Input()
  query: string[] = [];
}

また、当然だがクエリパラメータがない場合も想定する必要があるため、この query インプットプロパティの本当の型は string[] | string | undefined である。しかし誰もこんな型のインプットプロパティを扱いたくはない。そこで冒頭で触れたInput Transformsを使おう。

ちなみに、オブジェクトとクエリパラメータを相互に変換する振る舞いはUrlSerializerを独自に拡張することで変更できる。

配列への正規化

Input Transformsを使い、 query インプットプロパティを常に string[] 型として扱えるように正規化することができる。 normalizeQuery という関数でその変換処理を行うとすると、コンポーネント側は次のように書ける。normalizeQuerystring[] | string | undefined の引数を受け取って string[] を返す関数ならどんな実装でも問題ない。

function normalizeQuery(value: string | string[] | undefined): string[] {
  if (!value) {
    return [];
  }
  if (Array.isArray(value)) {
    return value;
  }
  return [value];
}

@Component({...})
export class Page {
  @Input({ transform: normalizeQuery })
  query: string[] = [];
}

実際に動作するサンプルコードをStackblitzで公開しているので、試してみてほしい。

まとめ

  • 長さ1の配列をクエリパラメータにセットすると、Routerはそれを配列としてパースできない。
  • クエリパラメータが存在しないことも考慮して正規化をする必要がある。
  • RouterのComponent Input BindingとInput Transformsを使うと、正規化された値を直接インプットプロパティで受け取ることができる。