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 チームのユビキタス言語に関する取り組み (私も参加しています) についての紹介です.

辞書を作る関数に TypeScript で執拗に型をつける

未来人のみなさまご機嫌いかがでしょうか. この記事が書かれた時点の TypeScript のバージョンは 3.6.4 です.

お題

以下の JavaScript の関数に TypeScript で型をつけることを考えます1.

function makeDict(prop, entries) {
  const dict = {};
  for (const entry of entries) {
    dict[entry[prop]] = entry;
  }
  return dict;
}

これは見ての通り, 配列から辞書を作ります2.

const entries = [
  { id: "a", name: "Foo" },
  { id: "b", name: "Bar" },
  { id: "c", name: "Baz" },
];

const dict = makeDict("id", entries);
// dict = {
//   "a": { id: "a", name: "Foo" },
//   "b": { id: "b", name: "Bar" },
//   "c": { id: "c", name: "Baz" },
// }

レベル 1

型をつけるといっても様々な段階があるので, 型によって何を保証したいかを明確にしつつ進めていきましょう.

まずは基本中の基本として, パラメータの prop は文字列, entries は配列であり, 戻り値は辞書であることを保証してみます3. これは簡単な型注釈を書けば良いだけですね.

type Dict = { [key: string]: any };

function makeDict(prop: string, entries: any[]): Dict {
  const dict: Dict = {};
  for (const entry of entries) {
    dict[entry[prop]] = entry;
  }
  return dict;
}

makeDict を使う側は特に変わりません.

const entries = [
  { id: "a", name: "Foo" },
  { id: "b", name: "Bar" },
  { id: "c", name: "Baz" },
];

const dict = makeDict("id", entries);

const a = dict.a;
// a: any = { id: "a", name: "Foo" }

レベル 1 のコード全体はこちら. 変数や式にカーソルを当てると型が表示されて便利です.

レベル 2

レベル 1 で型によって得られる保証は最低限のものなので, いくつか気になる部分があります.

まず, entries の要素が持っていなかったり, 持っていても string 型でなかったりするプロパティ名を prop に指定することができてしまいます. これらは以下のような, おそらく意図しないであろう結果となります.

const dict = makeDict("key", entries);
// dict = { "undefined": { id: "c", name: "Baz" } }

また dict.a のような辞書の中身の参照は any 型になってしまうため, entries の要素が本来どういったプロパティを持っていたのかの情報は失われてしまいます.

const dict = makeDict("id", entries);

const a = dict.a;
a.say(); // runtime error

レベル 2 では, これら 2 つのエラーを型検査で見つけられるようにしてみましょう.

まずは辞書の型を修正して, 具体的な型が得られるようにします. ここでは一般に T 型を持つ辞書の型を定義しておきます.

type Dict<T> = { [key: string]: T };

続いて makeDict に対する型付けは以下のようになります.

type Entry<P extends string> = { [P0 in P]: string };

function makeDict<P extends string, T extends Entry<P>>(
  prop: P, entries: T[],
): Dict<T> {
  const dict: Dict<T> = {};
  for (const entry of entries) {
    dict[entry[prop]] = entry;
  }
  return dict;
}

P は見ての通り prop の型で, "id" のような文字列のリテラル型となることを期待しています4. Tentries の各要素の型で, extends によって欲しい条件 (型の上界) を指定しています.

T の条件を詳しく見てみましょう. Entry<P> = { [P0 in P]: string }mapped type と呼ばれるもので, 一般的な話は参照先を見てもらえると良いのですが, ここでは Pリテラル型を期待しているので, たとえば P = "id" であったとすると { id: string } が得られます. 条件としてはこれを extends しているので, つまり T は少なくとも id: string を持ち, その他にもプロパティを持ち得る型ということになります.

実際に使ってみると, まず propentries に存在しないプロパティを指定すると, 期待通りに型エラーとなることがわかります. ここで makeDict に対する型引数は, 関数に与えられた引数から推論されて, 上記で期待した通り P = "id"P = "key" となっています.

const entries = [
  { id: "a", name: "Foo" },
  { id: "b", name: "Bar" },
  { id: "c", name: "Baz" },
];

const dict = makeDict("id", entries);   // ok
const dict2 = makeDict("key", entries); // type error

また辞書の中身を参照した場合も, 元々の型がきちんと維持されています.

const a = dict.a;
// a: { id: string, name: string } = { id: "a", name: "Foo" }

よかったですね. レベル 2 のコード全体はこちら.

レベル 3

さてレベル 2 で定義した Dict<T> には key に関する制約がないため, あらゆる文字列を使ってアクセスできてしまいます. このため, 適当なキーを使ってアクセスした場合は, 実際は存在しない (undefined) のに型は T ということになってしまいます5.

const x = dict.x;
// x: { id: string, name: string } = undefined

というわけでレベル 3 では, 型の上で辞書のキーの集合を持つようにして, 適当なキーではアクセスできないようにしてみましょう.

これを実現するためには, 当然 entries の各要素が持っている id の中身が型レベルで利用できるようになっている必要があります. このため, entriesconst assertion をつけて宣言しておきます.

const entries = [
  { id: "a", name: "Foo" },
  { id: "b", name: "Bar" },
  { id: "c", name: "Baz" },
] as const;
// entries: readonly [
//   { readonly id: "a", readonly name: "Foo" },
//   { readonly id: "b", readonly name: "Bar" },
//   { readonly id: "c", readonly name: "Baz" },
// ]

as const をつけることで, entries は単なる配列ではなく読み取り専用のタプル型となり, 要素ごとの詳細が維持されます.

続いて, この entries の型 (typeof entries) を辞書の型に変換することを考えてみましょう. まずは entries に対応する型を用意します. P はこれまでと同じく "id" といった文字列リテラル型を期待しています.

type Entry<P extends string> = { readonly [P0 in P]: string };
type Entries<P extends string> = readonly Entry<P>[];

次に一般的なユーティリティ型 Elem<T> を定義します. これは配列の要素の型を取得するもので, タプル型が与えられた場合は各要素の union type となります. 定義自体は index type を使っています.

type Elem<T extends readonly unknown[]> = T[number];

これらを使って辞書の型を定義してみます.

type Dict<P extends string, T extends Entries<P>> = {
  readonly [K in Key<P, T>]: Select<P, Elem<T>, K>
};
type Key<P extends string, T extends Entries<P>> = Elem<T>[P];
type Select<P extends string, E, K> =
  E extends { readonly [P0 in P]: K } ? E : never;

はい.

順を追って見ていきましょう. まず Key<P, T>T に含まれるキーの union type を表します. 具体的には Key<"id", typeof entries> = "a" | "b" | "c" となります.

次に Select<P, E, K>entries の要素の union type E からキー K を持つものだけを選び出します. この定義は conditional type を使っています.

具体的にどうなるかを P = "id", E = Elem<typeof entries>, K = "a" の場合に見てみましょう. まず E は以下のような union type であることに注意します.

E = { readonly id: "a", readonly name: "Foo" }
  | { readonly id: "b", readonly name: "Bar" }
  | { readonly id: "c", readonly name: "Baz" }

すると conditional type は union type に対して分配されるため, 結果は以下のようになり, 無事 id: "a" となるもののみが選び出されます.

Select<P, E, K>
  = (
      { readonly id: "a", readonly name: "Foo" } extends { readonly id: "a" }
        ? { readonly id: "a", readonly name: "Foo" } : never
    ) | (
      { readonly id: "b", readonly name: "Bar" } extends { readonly id: "a" }
        ? { readonly id: "b", readonly name: "Bar" } : never
    ) | (
      { readonly id: "c", readonly name: "Baz" } extends { readonly id: "a" }
        ? { readonly id: "c", readonly name: "Baz" } : never
    )
  = { readonly id: "a", readonly name: "Foo" } | never | never
  = { readonly id: "a", readonly name: "Foo" }

さて辞書の型 Dict<P, T> の定義に戻ると, T に含まれる各キー K に対して, それぞれ Select<P, Elem<T>, K> を割り当てる mapped type となっています.

type Dict<P extends string, T extends Entries<P>> = {
  readonly [K in Key<P, T>]: Select<P, Elem<T>, K>
};

実際 P = "id", T = typeof entries の場合を見てみると, 以下のように期待した辞書型が得られることがわかります.

Dict<P, T> = {
  "a": { readonly id: "a", readonly name: "Foo" },
  "b": { readonly id: "b", readonly name: "Bar" },
  "c": { readonly id: "c", readonly name: "Baz" },
}

あとは makeDict に適切に型をつければ完成です.

function makeDict<P extends string, T extends Entries<P>>(
  prop: P, entries: T,
): Dict<P, T> {
  const dict: any = {};
  for (const entry of entries) {
    dict[entry[prop]] = entry;
  }
  return dict;
}

any はここでは仕方なく使っていますが, 関数の内部に閉じ込めてあるため makeDict のユーザー側から見れば安全です6.

使い方は as const 以外は特に変わらず以下の通り.

const entries = [
  { id: "a", name: "Foo" },
  { id: "b", name: "Bar" },
  { id: "c", name: "Baz" },
] as const;

const dict = makeDict("id", entries);
const dict2 = makeDict("key", entries); // type error

const a = dict.a;
// a: { readonly id: "a", readonly name: "Foo" } = { id: "a", name: "Foo" }
const x = dict.x; // type error

レベル 3 のコード全体はこちら.

レベル 4

まだまだ続きます. ところで entries 内のの id に重複があった場合はどうなるかというと,

const entries = [
  { id: "a", name: "Foo" },
  { id: "b", name: "Bar" },
  { id: "c", name: "Baz" },
  { id: "a", name: "Qux" },
] as const;

const dict = makeDict("id", entries);

const a = dict.a;
// a: { readonly id: "a", readonly name: "Foo" }
//  | { readonly id: "a", readonly name: "Qux" }
//  = { id: "a", name: "Qux" }

となり, 型は間違ってはいないのですが曖昧になってしまいます. というわけでレベル 4 では id の重複を型レベルで検知してみましょう.

まずは一般的なテクニックの紹介です. 以下の ElimUnion<U>U が union type であった場合は never, そうでなければ U となる型です.

type ElimUnion<U> = ElimUnionSub<U, U>;
type ElimUnionSub<U, V> =
  U extends V ? ([V] extends [U] ? U : never) : never;

具体的に見てみると以下のとおり, 期待したものになっていることがわかります.

ElimUnion<"x">
  = "x" extends "x" ? (["x"] extends ["x"] ? "x" : never) : never
  = ["x"] extends ["x"] ? "x" : never
  = "x"

ElimUnion<"x" | "y">
  = ("x" extends "x" | "y" ? (["x" | "y"] extends ["x"] ? "x" : never) : never)
    | ("y" extends "x" | "y" ? (["x" | "y"] extends ["y"] ? "y" : never) : never)
  = (["x" | "y"] extends ["x"] ? "x" : never)
    | (["x" | "y"] extends ["y"] ? "y" : never)
  = never | never
  = never

既に見た conditional type の union type に対する分配を U に対して使いつつ, V に対しては [V] extends [U] のようにタプル型で包むことで分配を防いでいるところがポイントです.

これを使うと以下のような型が定義できます.

type ElimDuplicateKeyedEntries<P extends string, T extends Entries<P>> =
  ElimDuplicateKeyedEntriesSub<P, Elem<T>, Key<P, T>>;
type ElimDuplicateKeyedEntriesSub<P extends string, E, K> =
  K extends unknown ? ElimUnion<Select<P, E, K>> : never;

ElimDuplicateKeyedEntries<P, T>T の要素の union type からキーの重複があるものを取り除く型なのですが, やはり何なのかわかりづらいので P = "id", T = typeof entries (重複がある場合) の場合の具体例を見てみましょう.

ElimDuplicateKeyedEntries<P, T>
  = ElimDuplicateKeyedEntriesSub<"id", Elem<typeof entries>, "a" | "b" | "c">
  = ("a" extends unknown ? ElimUnion<Select<"id", Elem<typeof entries>, "a"> : never)
    | ("b" extends unknown ? ElimUnion<Select<"id", Elem<typeof entries>, "b"> : never)
    | ("c" extends unknown ? ElimUnion<Select<"id", Elem<typeof entries>, "c"> : never)
  = ElimUnion<Select<"id", Elem<typeof entries>, "a">
    | ElimUnion<Select<"id", Elem<typeof entries>, "b">
    | ElimUnion<Select<"id", Elem<typeof entries>, "c">
  = ElimUnion<{ readonly id: "a", readonly name: "Foo" } | { readonly id: "a", readonly name: "Qux" }>
    | ElimUnion<{ readonly id: "b", readonly name: "Bar" }>
    | ElimUnion<{ readonly id: "c", readonly name: "Baz" }>
  = never
    | { readonly id: "b", readonly name: "Bar" }
    | { readonly id: "c", readonly name: "Baz" }
  = { readonly id: "b", readonly name: "Bar" }
    | { readonly id: "c", readonly name: "Baz" }

というわけで本当に id が重複している要素が消えることが確認できました. ちなみに ElimDuplicateKeyedEntriesSub<P, E, K> では conditional type を union type の各要素に対するマッピングをするためだけに使っていて, K extends unknown の条件は常に成り立つようになっています (unknown は TypeScript における Top 型).

さて, これを使って id に重複がないことを検証してみましょう. 大まかな方針としては ElimDuplicateKeyedEntries<"id", Elem<typeof entries>> が, id に重複がなければ Elem<typeof entries> そのもの, 重複があれば上で見たように異なる型となることを利用します.

その前にひとつだけ便利な型を定義します.

type TupleWithIndex<T extends readonly unknown[]> = {
  [I in keyof T]: T[I] & { __index: I }
};

これはタプル型に対する mapped type で, TupleWithIndex<["x", "y"]> = ["x" & { __index: 0}, "y" & { __index: 1 }] のようになります. なぜこれが必要かというと, entries 内に id 以外もまったく同一の要素が出現した場合に, それらを互いにを区別するためにインデックスを使いたいからですね.

というわけで entriesid に重複がないことの表明は以下のように書けます.

type EntriesAreUniquelyKeyed<P extends string, T extends Entries<P>> =
  Elem<TupleWithIndex<T>> extends ElimDuplicateKeyedEntries<P, TupleWithIndex<T>> ? true : false;

type Assert<T extends true> = T;
type AssertEntriesAreUniquelyKeyed = Assert<EntriesAreUniquelyKeyed<"id", typeof entries>>;

EntriesAreUniquelyKeyed<P, T>T 内に重複がなければ true 型, 重複があれば false 型となります7. Assert<T> は型引数 Ttrue であることを要求するため, ここに false などが入った場合は型エラーとなります. これらを使うことで, 型レベル で id に重複がないことの表明 AssertEntriesAreUniquelyKeyed を書くことができました.

レベル 4 のコード全体はこちら.

レベル 5

ところでレベル 4 は entries 内の id に重複がないということについて, makeDict とは独立に表明を書いたのみでした. レベル 5 ではこの制約を makeDict に組み込んでみましょう.

いきなりですが次のような型を定義します.

type UniquelyKeyedEntries<P extends string, T extends Entries<P>> = {
  [I in keyof T]:
    T[I] extends Omit<ElimDuplicateKeyedEntries<P, TupleWithIndex<T>>, "__index">
      ? T[I] : never
};

これは T 内に重複したキーがなければ T そのもの, 重複したキーがあればそれらに対応する要素を never に変えてしまいます. これが実際そのようになっているのかを確認するのは読者への宿題とします8. Omit<T, K>TypeScript の標準ライブラリに入っているユーティリティ型の一つです.

ついでにずっと期待していただけで保証はしていなかった, prop の型 P が文字列リテラル型であるということの保証も入れてしまいましょう.

type SingletonString<T extends string> = string extends T ? never : ElimUnion<T>;

これは T が文字列リテラル型であれば T そのもの, そうでないもの (string 型や文字列リテラル型の union type) については never にしてしまいます.

これらを使って引数に対する保証を加えた makeDict は次のようになります.

function makeDict<P extends string, T extends Entries<P>>(
  prop: SingletonString<P>, entries: UniquelyKeyedEntries<P, T>,
): Dict<P, T> {
  const dict: any = {};
  for (const entry of entries) {
    dict[entry[prop]] = entry;
  }
  return dict;
}

使い方はこれまでと変わりません.

const entries = [
  { id: "a", name: "Foo" },
  { id: "b", name: "Bar" },
  { id: "c", name: "Baz" },
  // { id: "a", xxx: "Foo" }, // type error if exists
] as const;

const dict = makeDict("id", entries);
const dict2 = makeDict("id" as string, entries); // type error

ここで makeDict の型引数は P = "id", T = typeof entries と (期待通りかつ予想外に) 推論されています9. 引数が保証を満たさない場合はパラメータの型 (の一部) が never となり, 当然それを満たす値を与えることはできないため型エラーとなります.

レベル 5 のコード全体はこちら. お疲れさまでした.

(実は型パラメータを明示的に渡せばレベル 5 の保証は壊せるのですが, きっとそんなことはしないでしょう...)

あとがき

こんなコードが出てきたらどうしますか? 私だったらレベル 4 以上はふつうにテスト書いた方が良くないですかって言います.

参考文献


  1. entry[prop]"__proto__" が入ってくるとちょっと微妙ですが本題と関係ないので見て見ぬ振りをしてください.

  2. JavaScript のコード自体は別に配列でなくても iterable なら動くのですが, ここでは入力は配列ということにしておいてください.

  3. propSymbol でも良いのですが, 議論が煩雑になるだけなので割愛.

  4. ここでは期待しているだけで特に保証はしていないことに注意.

  5. 一応レベル 2 を弁護しておくと, これ自体は配列の境界外アクセスと似たようなもので, 利用側が気をつけて利用するという条件のもとではまったく正しいです.

  6. とはいえ関数内部のロジックが間違っていては元も子もないので, 私はこういうとき心の中で はいバリア〜 と宣言して, 型のかわりにテストを書いて正しさを保証したりします.

  7. 注意点として, ドキュメントにある通り, conditional type の union type に対する分配は extends の左側が型変数であった場合のみに起こるため, ここではその条件に合わず分配は起こりません.

  8. 言いたかっただけ.

  9. どうやらパラメータの型と実際に与えられた引数の型を突き合わせてヒントを集め, それらを元に型引数を推論し, さらにそれを元に具体化したパラメータの型と引数の型が合っているかを検証する, という感じになっているようです. なんかすごいけど壊れそうで怖いですね.