💪

【TypeScript】型パズルと関数数行で、型安全なリンクを実現する

2023/01/20に公開

はじめに

<a href="/users1/posts/2"></a>

お気づきでしょうか。リンクをタイポしてリンク切れを起こしてしまっています。正しくは、users1の間にスラッシュが入ります。
リンク切れ自体はE2Eテストなりで防げはします。それでも、もっと早いタイミングで気づけるに越したことはないです。さらに言えば、長い文字列をなんの補完もなしに打つのは効率が悪いという問題もあります。

そこで、型安全なリンクの出番です。

この記事の実現イメージ

この記事では、buildPath(path: パス文字列, params: パスパラメータやクエリ)のような関数を、バチバチに補完が効く状態にする手法を提案します。

補完が効いている様子

補完が効いている様子
パス文字列やパスパラメータ(:idの部分)を良い感じに補完してくれます。
また、長いパス文字列もあいまい検索の要領で絞り込むことができます。

使用イメージ

// ✅
buildPath("/posts"); // => /posts

// ✅
buildPath("/posts/:id", { id: 1 }); // => /posts/1

// ❌ パスパラメータが指定されていません
buildPath("/posts/:id");

またこの記事では、react-routerやvue-routerのようなルーティングライブラリを想定しています。後述の方法でNext.js, Nuxt.js, SvelteKitのようなファイルベースのルーティングにも対応可能です。

他の手法との比較

pathpidaという型安全リンクを実現するライブラリをご存じの方も多いでしょう。非常に便利で、Next.jsなどのpages/ディレクトリから以下のようなオブジェクトを生成してくれます。

export const pagesPath = {
  posts: {
    _id: (id: string | number) => ({
      $url: (url?: { hash?: string }) => ({
        pathname: "/posts/[id]" as const,
        query: { id },
        hash: url?.hash,
      }),
    }),
  },
};

export type PagesPath = typeof pagesPath;

使い勝手は次のとおりです。

import { pagesPath } from "../lib/$path";

// `/post/[id]`
pagesPath.post._pid(1).$url();

react-routerやvue-routerを使う際、pathpidaが生成してくれるようなオブジェクトを自前で書くのも1つの方法です。この記事ではこれをオブジェクト方式と呼ぶことにします。
提案手法とオブジェクト方式を様々な指標で比較したのが次の表です。

提案手法 オブジェクト方式
補完 ✅ 長いパスもあいまい検索のように絞り込める 何回も.を打って、1 階層ずつ掘り進んでいく必要がある
バンドルサイズ ✅ 短い関数だけなので、パスが膨大になっても大きくならない ⚠️ パスが膨大になると、巨大なオブジェクトが生成される

提案手法はオブジェクト方式の辛みをある程度解決していると考えます。

提案手法の実装

まず、パスとそのパスにおけるクエリ(?)やハッシュ(#)を以下のように定義しておきます。この型定義はパスが増えるたびに追加していきます。

type Paths = {
  "/posts": { query: { q: string; } hash: "section" };
  "/posts/:id": Record<never, never>; // クエリを取らない場合はこのように書きます
  // ...
};

つぎに、ちょっとした型パズルを用意します。

type GetParams<Path> = Path extends `${string}:${infer P}/${infer Rest}`
  ? P | GetParams<Rest>
  : Path extends `${string}:${infer P}`
  ? P
  : never;

(難しいと感じる方は【TypeScript】infer を理解するをご参照ください。)
この型パズルは以下のように、パス文字列からパラメータ部分(:idなど)を抽出します。

// GetParams<"/posts"> => never
// GetParams<"/posts/:id"> => "id"
// GetParams<"/posts/:id/comments/:commentId"> => "id" | "commendId"

さて、メインのbuildPath関数は以下のように実装します。

function buildPath<Path extends keyof Paths>(
  path: Path,
  ...params: GetParams<Path> extends never
    ? [params?: Paths[Path] & { hash?: string }]
    : [
        params: Record<GetParams<Path>, string | number> &
          Paths[Path] & { hash?: string }
      ]
): string {
  const [param] = params;
  if (param === undefined) return path;
  return (
    path.replace(/:(\w+)/g, (_, key) => (param as any)[key]) +
    ("query" in param
      ? "?" + new URLSearchParams(param.query).toString()
      : "") +
    (param.hash ? "#" + param.hash : "")
  );
}

params引数の型が何やら複雑ですね。これは「パスパラメータがあるときだけbuildPathの第2引数を必須にする」というのを実現しています。
(参考:【TypeScript】関数で 1 つめの引数に応じて 2 つめの引数のオプショナルを切り替える)
(一部、苦肉の策でas anyを用いています。よりよい方法、募集中です。)

buildPathの使用感は以下のとおりです。

buildPath("/posts", { q: "foo", hash: "section" }); // => /posts?q=foo#section
buildPath("/posts/:id", { id: 1 }); // => /posts/1

なお、react-routerなどを使うときはルーティングの定義のために、/posts/:idという文字列自体が欲しくなります。

<Route element={<PostShow />} path="/posts/:id">

これを解決するために、以下の関数を定義します。

const echoPath = <Path extends keyof Paths>(path: Path) => path;

受け取ったパスをただ返すだけの関数ですが、buildPathと同様、パスの補完が効いてくれます。

<Route element={<PostShow />} path={echoPath("/posts/:id"))}>
                                             {// ^ 補完が効いてくれる}

おまけ Next.js などのファイルベースルーティングへの対応

globなどを用いてディレクトリ構造から/posts, /posts/:idのようなパス文字列を生成すれば今回の手法が使えます。
拙作ですが type-safe-path というライブラリを作成しました。ファイルベースルーティングを採用しているフレームワーク向けに、CLIから型定義を生成し、提案手法と似たような buildPath を提供します。

まとめ

少し多めの型定義と数行程度の関数で型安全リンクを実現する方法を解説しました。この手法には以下のメリットがあります。

  • 補完が使いやすい: あいまい検索のように絞り込めます
  • バンドルサイズが軽量:型定義はJavaScriptに変換することで消えてくれるため、ランタイムには少量の関数しか残らない

今回のコードはこちらの TypeScript Playgroundで試せます。

GitHubで編集を提案

Discussion