型安全URL構築できる
ライブラリ routopia を作った話

@yKicchan

型安全にURLを構築できるライブラリroutopiaを作った話

自己紹介

きっちゃそ
:DeNA
Web Frontend
X @yKicchan
型安全にURLを構築できるライブラリroutopiaを作った話

目次

  1. 開発の背景
  2. routopia とは
  3. 実現方法
  4. まとめ

1. 開発の背景

1. 開発の背景

URL 構築における課題

  • typo、手打ちミス
  • パラメータの渡し忘れ、型の不一致
  • 更新漏れ、実装との乖離
  • エンコード漏れ
1. 開発の背景

既存のアプローチ

1. 開発の背景

身の上話

  • 歴史あるプロジェクトで OpenAPI や ProtoBuf などの自動生成が難しい背景があった
  • さまざまな要因からほしい要件に既存のライブラリが絶妙にマッチしなかった
  • そのためプロジェクトでは内製の API エンドポイント用 URL ビルダーを(他の人が)作っていた
  • エンドポイントのパスとパラメータを定義し、それにより型安全に URL を構築できるもの
1. 開発の背景

内製ライブラリの利用イメージ

export const api = {
  /** エンドポイントの説明 */
  ...createApiRoute("/path/[slug]")<{
    /** エンドポイント+メソッドの説明 */
    GET: {
      segments: {
        /** パスパラメータ slug の説明 */
        slug: string;
      };
      queries: {
        /** クエリパラメータ q の説明 */
        q: boolean;
      }
    };
  }>(),
} as const;
import { api } from "./path/to/api";

api["/path/[slug]"].get({
  segments: { slug: "to" },
  queries: { q: "test" }
});
// => https://api.example.com/path/to?q=test

api["/path/[slug]"].get();
//                  ^^^^^
// segment.slug が足りないよエラー

宣言的な定義により利用時に安全に URL を構築できる

1. 開発の背景

内製ライブラリ

  • よかったところ
    • FW非依存で型安全に URL を構築できる
    • 宣言的な記述をすればいいだけで難しさがない
    • キーが文字列のため曖昧検索のようにパスを探せる
    • URL に一貫性が生まれ、SWR などキャッシングに貢献
    • JSDocで各種パラメータの説明ができる
  • 課題に上がったところ
    • 定義時に補完が効かず使い方を(作者含めて)忘れがち
    • JSDocをエディタが参照できない
    • コードジャンプできない
1. 開発の背景

要件

  • 型安全に URL を構築したい
  • ベースの使い心地はそのままがいい
  • JSDocがエディタに認識されてほしい
  • コードジャンプできるようにしたい
  • 定義時にある程度補完が効いてほしい

2. routopia とは

2. routopia とは

定義したスキーマに従って
型安全に URL を構築できるビルダー

2. routopia とは

基本的な使い方

import { routes, type, empty } from 'routopia';

export const api = routes({
  "/path": {
    // No parameters
    get: empty,
  },
  "/path/[id]": {
    get: {
      params: {
        id: type as number,
      },
      queries: {
        // Queries can be optional
        q: type as string | undefined,
      },
    },
  },
});
import { api } from "./path/to/api";

api["/path"].get();
// => "/path"

api["/path/[id]"].get({ 
  params: { id: 123 }, 
  queries: { q: "query" },
});
// => "/path/123?q=query"

api["/path/[id]"].get({ 
  params: { id: 123 },
});
// => "/path/123"

api["/path/[id]"].get();
//                  ^^^^^
// params が足りないよエラー
2. routopia とは

デモ

2. routopia とは

特徴

  • 型安全なルート定義と URL 構築
  • DX を損なわないインターフェース
    • 型推論と自動補完によるスムーズな開発体験
    • オプショナルなパラメータの省略可否など細かい配慮
    • JSDocの表示や定義元へのコードジャンプが可能
  • template literal types による強力な推論結果
    • Next.js の typedRoutes もいけるはず(未確認)
  • Next.js の Catch-all Segments に対応
  • URL 構築特化でミニマムなため余計な機能や環境・FW依存はなし
2. routopia とは

Best Practice

import { routes, ExpectedSchema, empty, type } from 'routopia';

export function createMyApiRoutes<T extends ExpectedSchema<T>>(schema: T) {
  return routes("https://api.example.com", schema);
}

export const schema = { empty, type };

腐敗防止層として routopia をラップし隠蔽しつつ、BaseURL など一括設定する

import { createMyApiRoutes, schema } from './path/to/createMyApiRoutes';

export const usersApiRoutes = createMyApiRoutes({
  "/users": {
    get: schema.empty,
  },
});
import { usersApiRoutes } from './path/to/usersApiRoutes';

usersApiRoutes["/users"].get();
// => "https://api.example.com/users"

3. 実現方法

3. 実現方法

ひたすら型パズル

すべてを解説している余裕はないので一部抜粋して紹介します

3. 実現方法

Case1: パスパラメータの抽出

  • /path/[param] という文字列から param を抽出したい
  • これを { param: string | number } としたい
  • 可変長な [...param][[...param]] も考慮したい
  • inferConditional Types(extends ? : ) を駆使して実現する
3. 実現方法

パスパラメータの抽出パズル

type ExtractParams<Endpoint extends string> =
  Endpoint extends `${infer Before}[[...${infer Param}]]${infer After}`
  ? { [K in Param]: (string | number)[] | undefined } & ExtractParams<Before> & ExtractParams<After>
  : Endpoint extends `${infer Before}[...${infer Param}]${infer After}`
    ? { [K in Param]: (string | number)[] } & ExtractParams<Before> & ExtractParams<After>
    : Endpoint extends `${string}[${infer Param}]${infer After}`
      ? { [K in Param]: string | number } & ExtractParams<After>
      : unknown;

ExtractParams<"/users/[userId]/posts/[postId]">
// => { userId: string | number, postId: string | number }

ExtractParams<"/path/[...params]">
// => { params: (string | number)[] }
3. 実現方法

パスパラメータの抽出結果

  • パス文字列から期待するパラメータの型を抽出できた
  • これを使って typo や 渡し忘れを検知できるようになる
  • 同時に補完も効くようになる
3. 実現方法

Case2: 呼び出し時の引数の省略可否

  • 呼び出し時の引数を必須じゃないなら省略可能にしたい
  • 定義時に undefined を許容したプロパティを省略可能にしたい
  • 再帰的な必須プロパティチェックとオプショナル化が必要
3. 実現方法

必須プロパティの存在チェック

type PickRequired<T> = {
  [P in keyof T as T[P] extends undefined ? never : P]: T[P];
};

type HasRequired<T> = (
  T extends unknown[]
    ? false
    : T extends object
      ? keyof PickRequired<T> extends never
        ? HasRequired<T[keyof T]>
        : true
      : false
) extends false ? false : true;
3. 実現方法

必須でないプロパティのオプショナル化

type PickNullable<T> = {
  [P in keyof T as T[P] extends undefined ? P : never]: T[P];
};

type Optional<T> = {
  [K in keyof PickNullable<T>]?: T[K];
} & {
  [K in keyof PickRequired<T>]: T[K];
};
3. 実現方法

組み合わせていった結果

  • undefined を許容するプロパティだけか再帰的に判別
  • 当てはまるオブジェクトに ? を付与できた
  • つまり必須パラメータがなければ引数から省略可能になった
  • これにより呼び出し時に余計な引数を省略できるようになった
3. 実現方法

難解な型パズルはメンテナンス性が悪いので
マジでおすすめはしませんが
小手先のテクニックは役立つかもしれません

3. 実現方法

手っ取り早くテクニックを学ぶ・調べるなら
type-challenges が参考になるかも

実はやったことない

4. まとめ

4. まとめ

まとめ

  • 型安全に URL を構築できる routopia を作った
  • 主な特徴:
    • 推論や補完が効き、JSDoc 表示やコードジャンプ可否など DX に配慮した
    • FW や 環境に依存せず URL の構築に特化して使える
  • 実現方法:
    • TypeScript の高度な型の表現(型パズル)を駆使
    • 型は安全性以外にも開発体験を向上させることが可能
    • 型パズルは激毒である
4. まとめ

見返す用

zenn

EOF