🕌

【TypeScript】動的なパスを生成するための便利関数の紹介

2024/05/09に公開

概要

Next.jsやHonoを使用しているプロジェクトなどで、動的なパスの生成はよくある課題の一つです。
今回紹介する便利関数は、型安全を保ちながらURLの生成を簡単かつ効率的に行う方法を提供します。この関数は、静的ルートから複雑なパラメータを含む動的ルートまで、様々なURLパターンを柔軟に扱うことができます。

const PATH = {
  ROOT: "/",
  GROUP_USER: "/group/[group_id]/user/[user_id]",
  FAQ: "/faq",
} as const;

type ExtractBracketParams<T extends string> =
  T extends `${string}/[${infer Param}]${infer Rest}`
    ? { [K in Param | keyof ExtractBracketParams<Rest>]: string }
    : {};

const mergeParamsToBracketPath = (
  path: string,
  params?: Record<string, string>
): string => {
  const baseUrl = params
    ? Object.entries(params).reduce((prev, [key, value]) => {
        if (value === undefined || value === "") {
          return prev;
        }
        const next = prev.replace(`[${key}]`, value);
        return next;
      }, path)
    : path;

  const rest = params
    ? Object.entries(params).reduce((prev, [key, value]) => {
        const unused = String(path).indexOf(`[${key}]`) === -1;
        const notEmpty =
          ([undefined, ""] as unknown[]).includes(value) === false;
        return {
          ...prev,
          ...(unused && notEmpty && { [key]: value }),
        };
      }, {} as Record<string, string>)
    : undefined;

  return rest && Object.keys(rest).length
    ? `${baseUrl}?${new URLSearchParams(rest).toString()}`
    : baseUrl;
};

export const getPath = <T extends keyof typeof PATH>(
  key: T,
  ...[query]: (typeof PATH)[T] extends `${string}/[${string}]${string}`
    ? [ExtractBracketParams<(typeof PATH)[T]> & Record<string, string>]
    : [Record<string, string>?]
): string => {
  return mergeParamsToBracketPath(PATH[key], query);
};

// ▼利用例
getPath("GROUP_USER", { group_id: "1", user_id: "2" }); // '/group/1/user/2'
getPath("GROUP_USER", { group_id: "1", user_id: "2", xxx: "3" }); // '/group/1/user/2?xxx=3'
getPath("GROUP_USER", { group_id: "1" }); // ❌ 型error
getPath("GROUP_USER", {}); // ❌ 型error
getPath("GROUP_USER"); // ❌ 型error

getPath("FAQ"); // '/faq'
getPath("FAQ", {}); // '/faq'
getPath("FAQ", { xxx: "1" }); // '/faq?xxx=1'

コードの説明

コードは主に以下の部分から構成されます:

  1. URLパターンの定義
    • PATHオブジェクトで各ルートのパターンを定義します。この定義を使って、URLを生成する際に必要なパラメータを抽出します。
const PATH = {
  ROOT: '/',
  GROUP_USER: '/group/[group_id]/user/[user_id]',
  FAQ: '/faq',
} as const;
  1. パラメータの型抽出
    • ExtractRouteParams型は、与えられたURL文字列からパラメータ部分を抽出し、適切な型情報を生成します。
type ExtractBracketParams<T extends string> =
  T extends `${string}/[${infer Param}]${infer Rest}`
    ? { [K in Param | keyof ExtractBracketParams<Rest>]: string }
    : {};
  1. URL生成関数
    • mergeParamsToBracketPath関数は、基本のURLとクエリパラメータを受け取り、動的にURLを生成します。未使用のクエリパラメータはURLクエリストリングとして追加されます。
const mergeParamsToBracketPath = (
  path: string,
  params?: Record<string, string>
): string => {
  const baseUrl = params
    ? Object.entries(params).reduce((prev, [key, value]) => {
        if (value === undefined || value === "") {
          return prev;
        }
        const next = prev.replace(`[${key}]`, value);
        return next;
      }, path)
    : path;

  const rest = params
    ? Object.entries(params).reduce((prev, [key, value]) => {
        const unused = String(path).indexOf(`[${key}]`) === -1;
        const notEmpty =
          ([undefined, ""] as unknown[]).includes(value) === false;
        return {
          ...prev,
          ...(unused && notEmpty && { [key]: value }),
        };
      }, {} as Record<string, string>)
    : undefined;

  return rest && Object.keys(rest).length
    ? `${baseUrl}?${new URLSearchParams(rest).toString()}`
    : baseUrl;
};
  1. パス取得関数
    • getPath関数は、指定されたキーとクエリオブジェクトを受け取り、createUrlを使って最終的なURLを生成します。
export const getPath = <T extends keyof typeof PATH>(
  key: T,
  ...[query]: (typeof PATH)[T] extends `${string}/[${string}]${string}`
    ? [ExtractBracketParams<(typeof PATH)[T]> & Record<string, string>]
    : [Record<string, string>?]
): string => {
  return mergeParamsToBracketPath(PATH[key], query);
};

利用例

getPath("GROUP_USER", { group_id: "1", user_id: "2" }); // '/group/1/user/2'
getPath("GROUP_USER", { group_id: "1", user_id: "2", xxx: "3" }); // '/group/1/user/2?xxx=3'
getPath("GROUP_USER", { group_id: "1" }); // ❌ 型error
getPath("GROUP_USER", {}); // ❌ 型error
getPath("GROUP_USER"); // ❌ 型error

getPath("FAQ"); // '/faq'
getPath("FAQ", {}); // '/faq'
getPath("FAQ", { xxx: "1" }); // '/faq?xxx=1'

まとめ

この便利関数getPathの導入により、Next.jsプロジェクトでのURL生成が非常に柔軟かつ効率的に行えるようになりました。特に複数のパラメータを持つ動的なルートに対応する場合、この関数は型安全を保ちつつ、開発者が簡単にURLを構築できるようにしています。

この方法の大きな利点は、型システムを最大限に活用している点です。TypeScriptの強力な型推論能力を用いることで、URLパラメータの誤りをコンパイル時に検出できるため、ランタイムエラーのリスクを大幅に低減できます。これにより、開発者はより安心してコードを書くことができ、デバッグの時間も節約できます。

最後までお読みいただきありがとうございます。今回の記事がTypeScriptを使ったサービスの開発体験向上を考えられている皆さんの参考になれば幸いです。

おまけ

Next.js以外のライブラリではURLの動的な部分の定義をブラケット([])ではなく、コロン(:)で行うものもあります(ReactRouter, express, etc)。その場合の型定義がブラケットのものとは異なるものを定義する必要があったので紹介します。なお、Remixなどのようにドルマーク($)で動的な部分を宣言するものもありますが、その場合はコロンの部分をドルマークに差し替えることで対応可能となっています。

const PATHS_002 = {
  ROOT: "/",
  GROUP_USER: "/group/:group_id/user/:user_id",
  FAQ: "/faq",
} as const;

type ExtractColonParams<T extends string> =
  T extends `${string}/:${infer After}`
    ? After extends `${infer Param}/${infer Rest}`
      ? { [K in Param | keyof ExtractColonParams<Rest>]: string }
      : { [K in After]: string }
    : {};

const mergeParamsToColonPath = (
  path: string,
  params?: Record<string, string>
): string => {
  const baseUrl = params
    ? Object.entries(params).reduce((prev, [key, value]) => {
        if (value === undefined || value === "") {
          return prev;
        }
        const next = prev.replace(`:${key}`, value);
        return next;
      }, path)
    : path;

  const rest = params
    ? Object.entries(params).reduce((prev, [key, value]) => {
        const unused = path.indexOf(`:${key}`) === -1;
        const notEmpty =
          ([undefined, ""] as unknown[]).includes(value) === false;
        return {
          ...prev,
          ...(unused && notEmpty && { [key]: value }),
        };
      }, {} as Record<string, string>)
    : undefined;

  return rest && Object.keys(rest).length
    ? `${baseUrl}?${new URLSearchParams(rest).toString()}`
    : baseUrl;
};

export const getPath = <T extends keyof typeof PATHS_002>(
  key: T,
  ...[query]: (typeof PATHS_002)[T] extends `${string}/:${string}`
    ? [ExtractColonParams<(typeof PATHS_002)[T]> & Record<string, string>]
    : [Record<string, string>?]
): string => {
  return mergeParamsToColonPath(PATHS_002[key], query);
};

// ▼利用例
getPath("GROUP_USER", { group_id: "1", user_id: "2" }); // '/group/1/user/2'
getPath("GROUP_USER", { group_id: "1", user_id: "2", xxx: "3" }); // '/group/1/user/2?xxx=3'
getPath("GROUP_USER", { group_id: "1" }); // ❌ 型error
getPath("GROUP_USER", {}); // ❌ 型error
getPath("GROUP_USER"); // ❌ 型error

getPath("FAQ"); // '/faq'
getPath("FAQ", {}); // '/faq'
getPath("FAQ", { xxx: "1" }); // '/faq?xxx=1'

関連記事

Discussion