💧

Drupal の JavaScript で使用する Drupal.theme() の TypeScript 型定義を書く

2021/12/13に公開

Drupal Advent Calendar 2021 の13日目の記事です。

はじめに

今年、Drupal のコアで使用される JavaScript の TypeScript 定義を書いて公開しました
その際、Drupal.theme() の型を作成する際に、第1引数を決定したら残りの引数を自動決定する仕組みの実装に苦戦したので備忘録も兼ねて記事にしました。

TL;DR

下の二つを組み合わせる。

interface definedThemeMap {
  example: (str: string) => string
}
type themeName = keyof definedThemeMap

type themeFunction<T extends themeName> = Pick<definedThemeMap, T>[T]

type themeArguments<T extends ThemeName> = Parameters<ThemeFunction<T>>

theme: {
  <T extends keyof definedThemeMap>(func: T, ...args : themeArguments<T>) : ReturnType<ThemeFunction<T>>
}

Drupal.theme() について

Drupal では、HTML 断片を JavaScript ファイル内で定義して他から呼び出す仕組みがあります。(以下、テーマ定義とします)
テーマ定義の仕方は Drupal.theme() に対して、任意のプロパティを設定し(以下、設定されたプロパティをテーマ ID とします)、HTML 断片となる文字列などを返す関数を作成します。下記は example をテーマ ID として所定の <div> タグを返す例です。

Drupal.theme.example = function (str) {
  return `<div class="example" title="${Drupal.checkPlain(str)}">例</div>`;
};

これを呼び出す際は、Drupal.theme() の第一引数に定義されたテーマ ID を代入し、第二引数以降で定義した関数の引数を入れていきます。(具体的な実装は Drupal のレポジトリを確認してください)

const themingExample = Drupal.theme('example', '例');

この仕組みであることから以下の二つが実装のネックになりました。(特に後者)

  • ユーザが自由にテーマ定義を出来る
  • テーマ定義の引数の数及び引数と戻り値の型が可変

ユーザが自由にテーマ定義を出来ることへの対応

テーマ定義用インターフェースを定義して、後からのテーマ定義の追加を可能にします。
definedThemeMap というインターフェースにテーマ ID と引数、戻り値の型を記述していきます。

interface definedThemeMap {
  example: (str: string) => string
}

テーマ定義の引数の数及び引数と戻り値の型が可変になることへの対応

テーマ定義の呼び出し方は、引数の数および引数と戻り値の型が可変とは言え下記のパターンしかありません。

// 引数がなければ
Drupal.theme('テーマ ID'): テーマ定義の戻り値の型
// 引数があれば
Drupal.theme('テーマ ID', ...テーマ定義の引数): テーマ定義の戻り値の型

この呼び出し方から、テーマ ID の値によって引数の数及び引数と戻り値の型が決まるような型定義を書ければ、求めたい型が得られそうです。
そのためには、前述の definedThemeMap インターフェースからテーマ ID とそれに紐付く関数の引数の型と返り値の型を取得し、それを Drupal.theme() で呼び出すときの型定義に出来れば良さそうです。この手順を下記の3つに分けて説明します。

  1. テーマ ID の型の取得
  2. テーマ ID に紐付く関数の引数の型の取得
  3. Drupal.theme() で呼び出す部分の型の作成(テーマ ID に紐付く関数の戻り値の型の取得)

1. テーマ ID の型の取得

テーマ ID の型はテーマ定義用のインターフェースの中にしか存在しません。よって、下記の通りになります。

type themeName = keyof definedThemeMap

2. テーマ ID に紐付く関数の引数の型の取得

前述の通り definedThemeMap インターフェースにテーマ ID に紐付く関数があり、関数内に引数の型は定義されています。テーマ ID を変数として引数の型を取り出すためジェネリクスと Utility Types を組み合わせて定義します。
Pick を使い、definedThemeMap インターフェースからテーマ定義の関数を取り出します。インターフェースの中から取り出すため、Pick<definedThemeMap, T>[T] の形で取り出します。(こうしないと、(str: string) => string ではなく、"example": (str: string) => string のまま取り出されます)
テーマ定義の関数を取り出した後、Parameters を使って引数の型を取り出します。

// テーマ ID に紐付く関数
type themeFunction<T extends themeName> = Pick<definedThemeMap, T>[T]
// 取り出した関数に紐付く引数の型
type themeArguments<T extends themeName> = Parameters<themeFunction<T>>

3. Drupal.theme() で呼び出す部分の型(テーマ ID に紐付く関数の戻り値の型の取得)

最後に Drupal.theme() で呼び出すときに、テーマ ID によって引数と戻り値の型を自動的に変更するようにする部分を実装が必要です。結論から言うと、ジェネリクスと Utility Types を使用して下記の定義を書けば目的の実装が出来ます。(この辺は、lib.dom.d.ts の document.createElement() の実装などが参考になりました)

<T extends themeName>(func: T, ...args : themeArguments<T>) : ReturnType<themeFunction<T>>

lib.dom.d.ts の document.createElement() では、createElement() の第1引数に HTML 要素を示す文字列(a、div など)を設定すると、その要素に応じた HTMLElement が返ってきます。この型定義は下記になります。

lib.dom.d.ts
createElement<K extends keyof HTMLElementTagNameMap>(tagName: K, options?: ElementCreationOptions): HTMLElementTagNameMap[K];

HTMLElementTagNameMap は、タグとそのタグが HTMLElement を継承した DOM インターフェース(HTMLAnchorElement, HTMLDivElement など)の型のマッピングをしている TypeScript のインターフェースです。この TypeScript のインターフェース同様にプロパティと同じ文字列を関数の引数に設定して、プロパティに紐付く型を取り出す仕組みを使用すれば、Drupal のテーマ定義の場合でも取り出せます。

作成した型定義をくっつける

テーマ定義用インターフェースと作成したテーマ ID の値によって引数の数及び引数と戻り値の型が決まる型定義を交差型でくっつければ、Drupal.theme() の型定義となります。

余談ですが、Drupal.theme() の返り値は JSDoc によれば、string|object|HTMLElement|jQuery なので、返り値がそれ以外だった場合 never を返せばテーマ定義の関数の戻り値が妥当かが分かります。(実装したライブラリでは、そこまで厳密に判定していません)

  theme: {
    <T extends themeName>(func: T, ...args : themeArguments<T>) : ReturnType<themeFunction<T>> extends string|object|HTMLElement|JQuery<any> ? ReturnType<themeFunction<T>> : never
  } & definedThemeMap

おわりに

Drupal.theme() の型をテーマ定義用インターフェースとジェネリクスと Utility Types の組み合わせることで、第1引数が決まれば自動的に残りの引数の型が決まる仕組みを実装しました。

definedThemeMap に設定したテーマ ID が候補に出てくる

テーマ ID の絞り込み

placeholder に紐付く関数の引数の型の取得と関数の戻り値の型がサジェストされる
今回 Drupal.theme() の型定義を実装してみて、改めて TypeScript の型表現力の高さを実感しました。型定義は、その気になれば麻雀の役判定まで出来るようですが、私個人はまだ Template Literal Typeinfer のような TypeScript の強力な機能を使いこなせていないので、学習を継続していきたいところです。

Discussion