Tagged Templates でたのしい Router & Reverse Router

この記事は はてなエンジニア Advent Calendar 2019 15 日目の記事です. 昨日は id:polamjag さんによる Next.js で Google Analytics を使う・2019年冬 - polamjaggy でした.

qiita.com

こんにちは, Mackerel 開発チームでアプリケーションエンジニアをしている id:susisu です.

Mackerel は現在フロントエンドのフレームワークの AngularJS (バージョン 1 系) から React への移行を開始しており, 私はそのメインの担当エンジニアとして日々 TypeScript と向き合いながら, 色々と実験して遊んで暮らしています. この記事ではそういった実験の中で見つけたテクニックの一つである, tagged templates を使って router と reverse router を便利に記述する方法を紹介します.

未来人向け情報: 記事執筆時点での TypeScript の最新バージョン = 動作確認しているバージョンは 3.7.3 です.

Router & reverse router

さていきなりですが, router と reverse router は大まかには以下のような (部分) 関数と考えられます.

  • Router: パス → (名前, パラメータ)
  • Reverse router: (名前, パラメータ) → パス

Router はパスが与えられると, 事前に与えられた route のリストからパスに一致するものを見つけ出し, その名前と, パスから抽出したパラメータを返します. ここでの名前には通常コントローラーやコンポーネントが対応づけられていて, 抽出されたパラメータを使ってそれらを呼び出すといったことが行われます.

Reverse router はその逆で, route の名前とパラメータから, そこに辿り着くためのパスを返します.

(ところで片仮名で「ルート」と書いてしまうと route / root が紛らわしいため, この記事中では一貫して route / root と書くことにします. とはいえ root は登場しないのですが.)

path-to-regexp

このような処理を記述するために利用可能なライブラリとして path-to-regexp があります.

path-to-regexp が提供する match 関数はパスのパターンを受け取って関数を作り, その関数はパスを受け取って, パターンに一致したかどうかと, 一致した場合はパスから抽出したパラメータを返します.

import { match } from "path-to-regexp";

// fromPath: (path: string) =>
//     false
//   | { path: string, index: number, params: object }
const fromPath = match("/user/:userId");

// res = {
//   path: "/user/42",
//   index: 0,
//   params: { userId: "42" },
// }
const res = fromPath("/user/42");

また compile 関数を使うと, match の逆で, 予め与えておいたパターンに対してパラメータを与えることで, パスの文字列を作ることができます.

import { compile } from "path-to-regexp";

// toPath: (params?: object) => string
const toPath = compile("/user/:userId");

// path = "/user/42"
const path = toPath({ userId: "42" });

これらを組み合わせることで router と reverse router をそれぞれ作ることができるはずです.

問題点

しかしながら上で定義した fromPath によって得られる / toPath に与えるパラメータの型は object となっており, 実際の { userId: string } のような形のデータに比べて曖昧になっています. このため, router からパラメータを受け取るコントローラーやコンポーネントは, パラメータを (一般には安全でない方法で) より詳細な型にキャストして利用することになりますし, reverse router を使う処理では, 正しくパラメータを与えられているかの保証を得ることができません.

この問題は matchcompile に対して型引数を具体的に与えることで解決することができます (toPath の引数が任意になってしまっていることにはひとまず目を瞑ります).

import { match, compile } from "path-to-regexp";

// fromPath: (path: string) =>
//     false
//   | { path: string, index: number, params: { userId: string } }
const fromPath = match<{ userId: string }>("/user/:userId");

// toPath: (params?: { userId: string }) => string
const toPath = compile<{ userId: string }>("/user/:userId");

これによってパラメータの型は具体的になりましたが, その一方でこの型引数はパターン中に含まれるプレースホルダ部分を繰り返し書いているだけで二度手間です. また, それらのプレースホルダを正しく網羅的に書けているかの保証はどこにもありません.

Tagged templates

さて一旦話を変えて, tagged templates について説明します.

Tagged templatesECMAScript 2015 から導入された構文・機能で, TypeScript でも同じものを使うことができます.

使い方としては以下のように, template literal の前に関数 (タグ) をつけると, 関数は文字列部分を分解した配列と, プレースホルダに入れられた値を引数として受け取ります.

function tag(
  strs: TemplateStringsArray, ...vals: readonly unknown[]
): { strs: TemplateStringsArray, vals: readonly unknown []} {
  return { strs, vals };
}

// x = {
//   strs: ["foo", "bar", "baz"],
//   vals: ["yo", 42],
// }
const x = tag`foo${"yo"}baz${42}baz`;

通常の (タグのない) template literals は文字列の補間に使われることが多いですが, tagged templates も文字列を補間しつつ, プレースホルダに入った値には適切にエスケープ処理を施す, といった用途にしばしば使われます.

// 例えば SQL のクエリに適切にエスケープされた値を補間する
const query = sql`SELECT * FROM users WHERE id = ${id}`;

しかし最初の例からわかるように, 関数がプレースホルダ部分から受け取る値や関数が返す値は何でも良く, より広い範囲に応用することができます.

Tagged templates の型推論

さて, TypeScript は関数の型引数を (値の) 引数から推論することができます.

function ref<T>(x: T): { current: T } {
  return { current: x };
}

// foo: { current: string }
// T = string が推論された
const foo = ref("foo");

関数の型引数に適切に上限を指定しておくことで, 引数にリテラルを渡した場合にリテラル型を推論させるようにすることもできます.

function ref2<T extends string>(x: T): { current: T } {
  return { current: x };
}

// bar: { current: "bar" }
// T = "bar" が推論された
const bar = ref2("bar");

Tagged templates のプレースホルダに関してもこれと同じ型推論が行われます. 例えば以下のようなタグに対してプレースホルダリテラル渡した場合, 型引数には渡されたリテラルのユニオン型が推論されます.

function tag2<T extends string>(
  strs: TemplateStringsArray, ...vals: readonly T[]
): { strs: TemplateStringsArray, vals: readonly T []} {
  return { strs, vals };
}

// y: { strs: TemplateStringsArray, vals: readonly ("a" | "b")[] }
// T = "a" | "b" が推論された
const y = tag2`foo${"a"}bar${"b"}baz`;

これはつまりプレースホルダの情報を (追加で注釈を与える必要なしに) 型の上で扱えるということでもあります. おや?

(ところで tag2 には tag2<string>`...` のように手で型引数を与えることができ, そうすることで T をより曖昧な型にすることは可能ではあります. 一方でそれは型推論させるのに比べて記述が冗長になるため, ふつうはそんなことはしないであろうと割り切って, 型推論させる使い方のみを想定することにします.)

Tagged templates を使って router & reverse router を記述する

プレースホルダの情報が型の上でも利用できるということは, 最初に path-to-regexp を使って router と reverse router を作ろうとしたときに出ていたような, 型を手書きする必要があるという問題が解決できそうです. というわけで実際に試してみましょう.

注意: ここで示しているものはあくまで PoC であり, バリデーションやエンコード / デコード処理, 細かい使い勝手の問題など, 議論の大枠とは関係のない詳細な部分については省略しています. もしこれらのコードを組み込むことを検討する場合にはこの点にご注意ください.

Route の記述

まずは route を表す型を決めましょう. ここでは以下のように, 名前 name と 2 つの関数 fromPath, toPath を持っている形にします.

type Route<N extends string, P extends {}, Q extends {}> = Readonly<{
  name: N,
  fromPath: (path: string) => undefined | P,
  toPath: (params: Q) => string,
}>;

name の型を string ではなくより具体的な型としているのは, これによって route 同士を型の上でも区別したいためです. パラメータの型を表す型引数が P, Q の 2 つあるのは後々の扱いを楽にするためですが, ひとまずはあまり気にしないでください.

このような route を作る関数 (タグ) は以下のように書けます.

function route<N extends string>(name: N) {
  return <K extends string = never>(
    fragments: TemplateStringsArray, ...keys: readonly K[]
  ): Route<N, Params<K>, Params<K>> => {
    const pattern = makePattern(fragments, keys);
    const _fromPath = match<Params<K>>(pattern);
    const fromPath = (path: string): undefined | Params<K> => {
      const res = _fromPath(path);
      return res ? res.params : undefined;
    };
    const toPath = compile<Params<K>>(pattern);
    return { name, fromPath, toPath };
  };
}

ここで Params<K> は, キーのユニオン型を辞書の型に変換します.

type Params<K extends string> = { [K0 in K]: string };

また makePattern は, 与えられたパスの文字列部分とパラメータを path-to-regexp が解釈できるパターン文字列に変換します.

function makePattern(fragments: readonly string[], keys: readonly string[]): string {
  let pattern = "";
  for (let i = 0; i < fragments.length; i++) {
    pattern += fragments[i];
    if (i < keys.length) {
      pattern += ":" + keys[i];
    }
  }
  return pattern;
}

route を実際に使ってみると, 以下のようにパラメータの型が期待通りに推論されていることがわかります.

// user: Route<"user", Params<"userId">, Params<"userId">>
const user = route("user")`/user/${"userId"}`;

// params = { userId: "42" }
const params = user.fromPath("/user/42");

// path = "/user/42"
const path = user.toPath({ userId: "42" });

プレースホルダが含まれない場合や, 複数含まれる場合でも問題ありません (routes の定義で型引数 K のデフォルトを never としているのがポイントです).

// home: Route<"home", Params<never>, Params<never>>
const home = route("home")`/home`;

// post: Route<"post", Params<"userId" | "postId">, Params<"userId" | "postId">>
const post = route("post")`/user/${"userId"}/post/${"postId"}`;

というわけで, route を記述する際にパラメータの型が曖昧になる / 具体的にするには手で注釈を書く必要がある, という問題は無事解決されました.

修飾子を表現する

path-to-regexp に渡すパターン中のパラメータには修飾子 (modifier) を付与することができ, パスのマッチングの挙動を変更することができます.

// res = {
//   path: "/page/foo/bar",
//   index: 0,
//   params: { path: [ "foo", "bar" ] },
// }
const res = match("/page/:path+")("/page/foo/bar");

// path = "/page/foo/bar"
const path = compile("/page/:path+")({ path: ["foo", "bar"] });

これを tagged templates を使って表現するには, 大まかには以下のように, パラメータのキーとして, 文字列リテラルだけでなく, キーと修飾子の情報を含むオブジェクトを受け取るようにします.

type Key<K extends string> = K | { key: K, modifier: Modifier };
type Modifier = "+" | ...;

function route<N extends string>(name: N) {
  return <K extends string = never>(
    fragments: TemplateStringsArray, ...keys: readonly Key<K>[]
  ): Route<N, Params<K>, Params<K>> => {
    // 省略
  };
}

修飾子を使った場合, 上記の例の通り, match で得られる / compile に渡すパラメータの型は文字列とは限らず, これを扱うコードはやや複雑になるため, ここでは詳細な実装は省略します.

以上のようにすることで, 修飾子を含んだ route は次のように記述することができます.

// home: Route<"page", Params<"path">, Params<"path">>
const page = route("page")`/page/${plus("path")}`;

ここで plus は修飾子を便利に記述するために定義した関数です.

function plus<K extends string>(key: K): { key: K, modifier: "+" } {
  return { key, modifier: "+" };
}

一部のパラメータを任意にする場合も, 基本的には Route<N, P, Q> の型引数のうちパラメータのキーを表す PQ をそれぞれ必須のものと任意のものの 2 つずつに分離し, fromPath, toPath の型を適切に変更することで表現が可能です. これについても詳細は長くなるため省略します.

Router

(ここから先はおまけ要素が強いです.)

Tagged templates によって, 型情報を与えつつ route を容易に記述できるようになったので, ここから実際に router や reverse router が利用可能な形で作成できることを確認しておきましょう.

ここでは一旦修飾子のことは忘れます.

まずは route を一箇所にまとめます. as const を使うことで配列 (タプル) の各要素の型を具体的なまま保っていることに注意してください.

const routes = [
  route("home")`/home`,
  route("user")`/user/${"userId"}`,
  route("post")`/user/${"userId"}/post/${"postId"}`,
] as const;

続いて router を定義します. ここでは以下のように, パスを引数にとり, 一致した route に対応する関数を呼び出し, その結果 T を返す, というところまでまとめて行ってしまうものを Router<T> とします.

type Router<T> = (path: string) => T;

次に各 route に関数を対応させることを考えましょう. ここでは対応づけは route の名前 → 関数 の辞書を使って行うことにします. このような辞書の型は次のように書けます.

type Bindings<D extends AbstractRoutesDict, T> = {
  [N in keyof D]: (params: NonNullable<ReturnType<D[N]["fromPath"]>>) => T
};

ここで AbstractRoutesDict は以下のような, route の名前 → route の辞書を表す抽象的な型です.

type AbstractRoutesDict = {
  [N in string]: Route<N, {}, never>
};

このような関数の対応づけの辞書と route のリスト routes から router を作る関数は以下のように書けます.

function makeRouter<R extends AbstractRoutes>(
  routes: R
): <T>(bindings: Bindings<Dict<"name", R>, T>) => Router<T> {
  return bindings => path => {
    for (const route of routes) {
      const res = route.fromPath(path);
      if (res) {
        // ここの any は避けられない
        return bindings[route.name as keyof Dict<"name", R>](res as any);
      }
    }
    throw new Error("no match");
  };
}

ここで AbstractRoutes は, AbstractRoutesDict と同じく, route のリストを表す抽象的な型です.

type AbstractRoute = Route<string, {}, never>;
type AbstractRoutes = readonly AbstractRoute[];

また Dict<P, T> はタプルを (heterogeneous な) 辞書に変換するような型で, 定義は以下です (これについての詳細は 辞書を作る関数に TypeScript で執拗に型をつける という記事に書きました).

type Dict_Entry<P extends string> = { readonly [P0 in P]: string };
type Dict_Entries<P extends string> = ReadonlyArray<Dict_Entry<P>>;
type Dict_Elem<T extends readonly unknown[]> = T[number];
type Dict_Key<P extends string, T extends Dict_Entries<P>> = Dict_Elem<T>[P];
type Dict_Select<P extends string, E, K> =
  E extends { readonly [P0 in P]: K } ? E : never;
type Dict<P extends string, T extends Dict_Entries<P>> = {
  readonly [K in Dict_Key<P, T>]: Dict_Select<P, Dict_Elem<T>, K>
};

makeRouter がカリー化して定義されているのはちょっとしたテクニックで, こうすることで bindingsリテラルとして引数に渡した場合でも T が正しく推論されるようになります (カリー化していない場合は型推論の際に T を先に具体化してしまうようで, T = unknown となってしまいます).

さて, ここまで長くなりましたがようやく router を作成できるようになりました. 文字列を返すだけの単純な router を作ると以下のようになります. ここでもし存在しないパラメータを使おうとすると型エラーとなります.

// router: Router<string>
const router = makeRouter(routes)({
  home: () => "Welcome!",
  user: ({ userId }) => `userId = ${userId}`,
  post: ({ userId, postId }) => `userId = ${userId}, postId = ${postId}`,
});

実際にパスを与えてみると, パスに応じて正しい関数が, パスから抽出されたパラメータとともに呼び出されていることが確認できます.

// res1 = "Welcome!"
const res1 = router("/home");

// res2 = "userId = 42"
const res2 = router("/user/42");

// res3 = "userId = 42, postId = foo"
const res3 = router("/user/42/post/foo");

ところで最初から routes を配列 (タプル) ではなく辞書で定義しておけばいくぶん簡単になる (複雑な型を持ち出さなくて済む) のでは, と思われたかもしれません. ここでは router によるパスのマッチングが route を書いた順番に行われる, という (おそらく) 直感的な挙動が得られるように, あえてこのようにしています. ふつうはパスのマッチングの順番が変わることで挙動が変わることは少ないと思うのですが, しかし現実は...

Reverse router

ここまで来てしまえば reverse router は簡単です. まずは reverse router の型を route の名前 → パスを組み立てる toPath 関数 の辞書と定義します.

type ReverseRouter<D extends AbstractRoutesDict> = {
  [N in keyof D]: D[N]["toPath"]
};

これを作る関数はとても簡単に書けます.

function makeReverseRouter<R extends AbstractRoutes>(
  routes: R
): ReverseRouter<Dict<"name", R>> {
  // ここの any は避けられない
  const rr: any = {};
  for (const route of routes) {
    rr[route.name] = route.toPath;
  }
  return rr;
}

実際に使ってみると以下のように正しくパスを組み立てることができ, また必要なパラメータが与えられなかった場合は型エラーになります.

const rr = makeReverseRouter(routes);

// path1 = "/home"
const path1 = rr.home({});

// path2 = "/user/42"
const path2 = rr.user({ userId: "42" });

// path3 = "/user/42/post/foo"
const path3 = rr.post({ userId: "42", postId: "foo" });

// error!
// Property 'postId' is missing in type '{ userId: string; }' but required in type 'Params<"userId" | "postId">'.
rr.post({ userId: "42" });

まとめ

記事中の router と reverse router の実装のコード全体はこちら.

Tagged templates に対する型推論を有効活用することで, 型情報を付加しつつ簡潔に router と reverse router を記述する方法を紹介しました. TypeScript は型の表現力が (ちょっと変な方向性で?) 高いので, 便利に使えると嬉しいですね.

明日は id:a-know さんによる Mackerel チームのユビキタス言語に関する取り組み (私も参加しています) についての紹介です.