URL 情報の型安全な管理個人的ベストプラクティス(Next.js 対応)

ページの遷移を行う際に、URL 情報をどのように管理するかについて、個人的なベストプラクティスをまとめる。
React を利用したアプリケーションであれば、react-router-dom を利用することが多いと思う。Next.js を利用したアプリケーションであれば、Next.js のルーティング機能を利用することになる。
いずれにしろ、ルーティングを管理するために、/usersのような、各ページの pathname 文字列を管理する機会はおそらく避けられないだろう。
ただし、これとは別に、

  • path parameter
  • query parameter

を利用する機会はとても多いと思う。開発者は、該当のページコンポーネントでどのような parameter が利用できるのかを把握しておく必要がある。
TS を扱うのであれば、type alias で管理することもできるだろうし、最悪コメントやドキュメントで管理することもできる。
ただ、どうせなら色々な場面で扱うことができるように、URL 情報を一元管理する仕組みを作っておきたい。
そこで、URL 情報を一元管理するための仕組みを作ってみたので紹介をする。

tyankatsu0105/sandbox-next-routingにまとめたので、このリポジトリをもとに紹介する。

URL に関する情報を管理する createURLObject 関数

  • pathname
  • path parameter
  • query parameter

を一つの関数で管理し、そこから URL を生成したり、いろいろな型を生成できるようにしていくことを目指す。
まずは完成形を提示する。

const USER = createURLObject({
  pathname: "/users/:userID",
  queryParameters: [
    {
      key: "userCategory",
      expectedValues: ["admin", "general"],
    },
    {
      key: "userStatus",
      expectedValues: ["active", "inactive"],
    },
  ],
} as const);

const path = USER.generatePath({
  query: {
    userCategory: "general",
    userStatus: "active",
  },
  path: {
    userID: "10101010",
  },
});
console.log({ path });
// "/users/10101010?userCategory=general&userStatus=active"

この createURLObject では、以下のようなことができる。

  • pathname を管理する
    • path parameter は pathname 内に含められているので、path parameter も管理できている
  • query parameter を管理する
    • key 文字列を管理する
    • 想定できる value に来るであろう文字列を管理する
  • generatePath 関数で path 文字列を生成する
    • query プロパティで query parameter の key と value を指定できる
      • key と expectedValues をもとに、プロパティの型推論が効く
    • query プロパティで path parameter の key と value を指定できる
      • pathname 内の path parameter 文字列を元に、プロパティの型推論が効く

つまり、createURLObject の引数に渡したオブジェクトを元に、generatePath 関数を型安全にしているというものだ。
しかも、createURLObject の引数に渡すときに、コメントを添えていれば、query parameter はどういうことを想定したものなのかを書き残すことができる。

const USER = createURLObject({
  pathname: "/users/:userID",
  queryParameters: [
    {
      /**
       * ユーザーの種類を表す
       */
      key: "userCategory",
      /**
       * - admin => 管理者
       * - general => 一般ユーザー
       */
      expectedValues: ["admin", "general"],
    },
    {
      /**
       * ユーザーのステータスを表す
       */
      key: "userStatus",
      /**
       * - active => アクティブ
       * - inactive => 非アクティブ
       */
      expectedValues: ["active", "inactive"],
    },
  ],
} as const);

ここからは、この createURLObject をメインに解説していく。

createURLObject 関数の実装

export type URLObject = {
  readonly pathname: string;
  readonly queryParameters: readonly {
    key: string;
    expectedValues?: readonly string[];
  }[];
};

export const createURLObject = <
  Pathname extends URLObject["pathname"],
  QueryParameters extends URLObject["queryParameters"]
>(urlObject: {
  /**
   * {@link URLObject['pathname']}
   */
  pathname: Pathname;
  /**
   * {@link URLObject['queryParameters']}
   */
  queryParameters: QueryParameters;
}) => {
  const { generatePath } = generatePathCreator(urlObject);

  return {
    pathname: urlObject.pathname,
    /**
     * 型のために利用する。実値での利用は禁止。
     */
    __FOR_TYPE__QUERY_PARAMETERS: urlObject.queryParameters,
    generatePath,
  };
};

URLObject という型で、createURLObject の引数に渡すオブジェクトの型を定義している。
createURLObject では、generics を利用して、const assertion した引数のオブジェクトの literal 型を保持するようにしている。ここで保持することで、generatePath 関数のときに型推論が効くようになる。

export const createURLObject = <
  Pathname extends URLObject["pathname"],
  QueryParameters extends URLObject["queryParameters"]
>(urlObject: {
  /**
   * {@link URLObject['pathname']}
   */
  pathname: Pathname;
  /**
   * {@link URLObject['queryParameters']}
   */
  queryParameters: QueryParameters;
}) => {
  const { generatePath } = generatePathCreator(urlObject);

  return {
    pathname: urlObject.pathname,
    /**
     * 型のために利用する。実値での利用は禁止。
     */
    __FOR_TYPE__QUERY_PARAMETERS: urlObject.queryParameters,
    generatePath,
  };
};

// const USER: {
//   pathname: "/users/:userID";
//   __FOR_TYPE__QUERY_PARAMETERS: readonly [{
//       readonly key: "userCategory";
//       readonly expectedValues: readonly ["admin", "general"];
//   }, {
//       readonly key: "userStatus";
//       readonly expectedValues: readonly [...];
//   }]
// }
export const createURLObject = (urlObject: {
  /**
   * {@link URLObject['pathname']}
   */
  pathname: URLObject["pathname"];
  /**
   * {@link URLObject['queryParameters']}
   */
  queryParameters: URLObject["queryParameters"];
}) => {
  const { generatePath } = generatePathCreator(urlObject);

  return {
    pathname: urlObject.pathname,
    /**
     * 型のために利用する。実値での利用は禁止。
     */
    __FOR_TYPE__QUERY_PARAMETERS: urlObject.queryParameters,
    generatePath,
  };
};

// const USER: {
//   pathname: string;
//   __FOR_TYPE__QUERY_PARAMETERS: readonly {
//       key: string;
//       expectedValues?: readonly string[] | undefined;
//   }[]
// }

また、__FOR_TYPE__QUERY_PARAMETERS というプロパティを返却しているが、これはReturnType<typeof createURLObject>['__FOR_TYPE__QUERY_PARAMETERS']などをして、query parameter に関する literal 型を再利用をするため露出している。実際には利用しないようにしたいので、特徴的な名前にしているが(__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIREDを意識している)、本来は露出させたくないため、いい方法があれば教えてほしい。

generatePathCreator 関数の実装

generatePath 関数を返す関数。
generatePath 関数単体でテストを書きたかったので、型のために引数に URLObject を渡している。

export const generatePathCreator = <CreatedURLObject extends URLObject>(
  urlObject: CreatedURLObject
) => {
  const generatePath = (parameters: {
    query: Partial<QueryParameterMap<CreatedURLObject["queryParameters"]>>;
    path: Record<PathParams<CreatedURLObject["pathname"]>, string>;
  }) => {
    const replacedPath = getReplacedPath({
      pathname: urlObject.pathname,
      path: parameters.path,
    });

    const queryString = getQueryString({ query: parameters.query });

    return `${replacedPath}${queryString}`;
  };

  return {
    generatePath,
  };
};

getReplacedPath は pathname 内の path parameter を置換する関数。
getQueryString はクエリ文字列を生成する関数。
この2つを組み合わせて、最終的に path 文字列を生成している。

export const getReplacedPath = (params: {
  pathname: string;
  path: Record<string, string>;
}) => {
  const pathEntries = Object.entries(params.path);
  const hasPathParameter = pathEntries.length > 0;

  if (!hasPathParameter) return params.pathname;

  const result = pathEntries.reduce((acc, [key, value]) => {
    return acc.replace(`:${key}`, value);
  }, params.pathname);

  return result;
};
export const getQueryString = (params: { query: Record<string, unknown> }) => {
  const queryEntries = Object.entries(params.query);
  const hasQueryParameter = queryEntries.length > 0;

  if (!hasQueryParameter) return "";

  const queryString = queryEntries
    .map(([key, value]) => `${key}=${value}`)
    .join("&");

  const result = `?${queryString}`;

  return result;
};

QueryParameterMap 型の実装

export type QueryParameterMap<
  QueryParameters extends URLObject["queryParameters"]
> = {
  [Key in QueryParameters[number]["key"]]: Extract<
    QueryParameters[number],
    { key: Key }
  > extends {
    key: Key;
    expectedValues: readonly (infer ExpectedValues)[];
  }
    ? ExpectedValues | (string & {})
    : string;
};
{
  [
    {
      key: "userCategory",
      expectedValues: ["admin", "general"],
    },
    {
      key: "userStatus",
      expectedValues: ["active", "inactive"],
    },
  ];
}

のような配列型を渡すと、以下のような型が生成される。

{
  userCategory: "admin" | "general" | (string & {});
  userStatus: "active" | "inactive" | (string & {});
}

| stringではなく、| (string & {})としているのは、string 型を許容しつつ string literal を活かすためである。
詳しくは TypeScript で string 型の値に自動補完を効かせるを参照。

PathParams 型の実装

export type PathParams<Path extends string> =
  Path extends `:${infer Param}/${infer Rest}`
    ? Param | PathParams<Rest>
    : Path extends `:${infer Param}`
    ? Param
    : Path extends `${any}:${infer Param}`
    ? PathParams<`:${Param}`>
    : never;

/user/:userID/:postIDのような string literal を渡すと、userID | postIDという string literal union を返す。
実は著者はreact router の generatePath 関数型安全にした本人なのだが、そのときの実装を参考にしている。

Next.js の useRouter の query プロパティを型安全にする

useRouter().query には、path parameter と query parameter の値が同列のオブジェクトとして格納されているが、generics を許容しているわけでもなく、型安全に扱うことができない。しかし、先程の createURLObject の pathname と__FOR_TYPE__QUERY_PARAMETERS を利用することで、type assertion になってしまうが、型安全に扱うことができる。
generics に createURLObject で生成した URLObject を渡すことで、useRouter().query の型を推論可能にできる。

export const getTypedQuery = <
  URLObject extends Omit<ReturnType<typeof createURLObject>, "generatePath">
>(
  router: ReturnType<typeof useRouter>
) => {
  type QueryParameters = Partial<
    QueryParameterMap<URLObject["__FOR_TYPE__QUERY_PARAMETERS"]>
  >;
  type PathParameters = Record<PathParams<URLObject["pathname"]>, string>;

  const query = router.query as QueryParameters & PathParameters;

  return { query };
};

以上が createURLObject を支える機能だ。

迷ったこと

generatePath 関数の分離

generatePath を createURLObject から切り離すことも検討したが、利用する際に

generatePath(URLs.USER, { path: { userID: "1" } });

のように、createURLObject で作った URLObject を渡す必要があり、一手間かと思い、createURLObject の返却の中に入れた。

satisfies の利用

今回はsatisfiesを使わずに実装を作ったが、
createURLObject のような関数を使わずとも、satisfies + const assertion を利用すれば、createURLObject に渡すオブジェクトを、文字列も literal 型で型推論できる。

const USER = {
  pathname: "/users/:userID",
  queryParameters: [
    {
      /**
       * ユーザーの種類を表す
       */
      key: "userCategory",
      /**
       * - admin => 管理者
       * - general => 一般ユーザー
       */
      expectedValues: ["admin", "general"],
    },
    {
      /**
       * ユーザーのステータスを表す
       */
      key: "userStatus",
      /**
       * - active => アクティブ
       * - inactive => 非アクティブ
       */
      expectedValues: ["active", "inactive"],
    },
  ],
} as const satisfies URLObject;

// const USER: {
//     readonly pathname: "/users/:userID";
//     readonly queryParameters: readonly [{
//         readonly key: "userCategory";
//         readonly expectedValues: readonly ["admin", "general"];
//     }, {
//         readonly key: "userStatus";
//         readonly expectedValues: readonly [...];
//     }];
// }

これを利用すれば構成がシンプルになるので satisfies を利用できる環境であれば、積極的に利用することを勧める。

まとめ

generatePath によって path 文字列を返せるので、遷移先の path がほしい場面では有効である。しかも型安全に組み立てられるというおまけ付きである。
さらに、createURLObject を使っている箇所を見ればどのような query parameter を想定しているのか一目瞭然であるので、「仕様をコードで表現」することができる。
「よりシンプル」に、しかし「型安全」に、更に「仕様を実装に落とし込められる」ようなものがないか、常に模索しながらコードを書いてい着たいと思った。