⛱️

TypeScriptで型安全なURL生成: パスとクエリパラメータの埋め込み

2024/07/08に公開

動機

  1. パス/クエリパラメータを埋め込むと型がstringになるのはさみしい
const url = new URL('http://example.com')
url.searchParams.append('foo', 1);
const result = url.href; // http://example.com/?foo=1
// ^? string 
// `http://example.com/?${string}` ぐらいほしい
  1. パスパラメータを型安全に指定したい(エディタで補完してほしい)
const setPathParams = (path, pathParams) =>
  path.replace(/{(.*?)}/g, (_, key) => pathParams[key]);
// postIdではなく、正しくはuserIdなので型エラーになってほしい
setPathParams('/users/{userId}', { postId : 1 });

できたもの


TS Playgroundで確認

クエリパラメータの埋め込みの実装

まず、簡単なクエリパラメータの方からやっていきます。
URLSearchParams オブジェクトでクエリパラメータを生成しています。

const setQueryParams = (
  path: string,
  queryParams: Record<string, number | string>
) => {
  const searchParams = new URLSearchParams(
    Object.entries(queryParams).map(([key, value]) => [key, value.toString()])
  );

  return `${path}?${searchParams.toString()}`;
};

const result = setQueryParams('/path', { foo: 'bar' }); // /path?foo=bar
// ^? string

返り値は string になります。

返り値の型を /path?${string} にする

返り値の型が string だとさみしいので、型引数とテンプレートリテラルで指定してあげます。

const setQueryParams = <const T extends string>(
  path: T,
  queryParams: Record<string, number | string>
): `${T}?${string}` => {
  const searchParams = new URLSearchParams(
    Object.entries(queryParams).map(([key, value]) => [key, value.toString()])
  );

  return `${path}?${searchParams.toString()}`;
};

const result = setQueryParams('/path', { foo: 'bar' }); // /path?foo=bar
// ^? `/path?${string}`

これでクエリパラメータは完成です。

パスパラメータの埋め込みの実装

String.prototype.replace と正規表現で { } で囲われた部分を置き換えています。

const setPathParams = (
  path: string,
  pathParams: Record<string, number | string>
) => {
  return path.replace(/{(.*?)}/g, (_, key) =>
    String(pathParams[key as keyof typeof pathParams])
  );
};

const result = setPathParams('/users/{id}', { id: 1 }); // /users/1
// ^? string

返り値は string になります。

返り値の型を /users/{$string} にする

まず、型引数をとります。しかし、パスパラメータのようにテンプレートリテラルでは返り値の型を表現できません。

const setPathParams = <const T extends string>(
  path: T,
  pathParams: Record<string, number | string>
) => {
  return path.replace(/{(.*?)}/g, (_, key) =>
    String(pathParams[key as keyof typeof pathParams])
  );
};

const result = setPathParams('/users/{id}', { id: 1 }); // /users/1
// ^? string

そこで、/users/{id} のような文字列を /users/{$string} に変換する型を定義します。

infer を使ってパースし、パスパラメータが入る箇所({ } で囲まれた部分)を string 型に置き換えていきます。再帰的に全ての箇所に対して処理します。

type ReplacePath<T extends string> =
  T extends `${infer Start}{${infer Param}}${infer Rest}`
    ? `${Start}${string}${ReplacePath<Rest>}`
    : T;

as で無理やり返り値の型にします。

const setPathParams = <const T extends string>(
  path: T,
  pathParams: Record<string, number | string>
): ReplacePath<T> => {
  return path.replace(/{(.*?)}/g, (_, key) =>
    String(pathParams[key as keyof typeof pathParams])
  ) as ReplacePath<T>;
};

const result = setPathParams('/users/{id}', { id: 1 }); // /users/1
// ^? `/users/{$string}`

引数の型を定義する

次に、引数のために /user/{id} から { id: string | number } という型を生成したいです。

ReplacePath と同様に infer でパースして、[K in Param] でプロパティを生成します。後は、再帰的にインターセクション型で結合していきます。

type PathParams<T extends string> =
  T extends `${infer Start}{${infer Param}}${infer Rest}`
    ? PathParams<Rest> & { [K in Param]: number | string }
    : object;

PathParams<T> を引数 pathParams の型に指定します。

const setPathParams = <const T extends string>(
  path: T,
  pathParams: PathParams<T>
): ReplacePath<T> => {
  return path.replace(/{(.*?)}/g, (_, key) =>
    String(pathParams[key as keyof typeof pathParams])
  ) as ReplacePath<T>;
};

const result = setPathParams('/users/{id}', { postId: 1 });
// ^? Type Error

これで、パスに存在しない postId をプロパティに指定すると型エラーになってくれます。

パス/クエリパラメータを同時に扱うラッパー関数の実装

パスパラメータ → クエリパラメータの順番で呼び出すだけです。

const setParams = <const T extends string>(
  path: T,
  params: {
    path: PathParams<T>;
    query: Record<string, number | string>;
  }
): `${ReplacePath<T>}?${string}` => {
  return setQueryParams(setPathParams(path, params.path), params.query);
};

後は、引数が指定されなかったときの型の分岐をオーバーロードで実装して完成です。

ソースコード全体
type PathParams<T extends string> =
  T extends `${infer Start}{${infer Param}}${infer Rest}`
    ? PathParams<Rest> & { [K in Param]: number | string }
    : object;

type ReplacePath<T extends string> =
  T extends `${infer Start}{${infer Param}}${infer Rest}`
    ? `${Start}${string}${ReplacePath<Rest>}`
    : T;

type SetPathParams = {
  <const T extends string>(path: T, pathParams: PathParams<T>): ReplacePath<T>;
  <T extends string>(path: T, pathParams?: undefined): T;
};

const setPathParams: SetPathParams = <const T extends string>(
  path: T,
  pathParams?: PathParams<T>,
) => {
  if (pathParams === undefined) return path;

  return path.replace(/{(.*?)}/g, (_, key) =>
    String(pathParams[key as keyof PathParams<T>]),
  ) as ReplacePath<T>;
};

type SetQueryParams = {
  <const T extends string>(
    path: T,
    queryParams: Record<string, number | string>,
  ): `${T}?${string}`;
  <T extends string>(path: T, queryParams?: undefined): T;
};

const setQueryParams: SetQueryParams = <const T extends string>(
  path: T,
  queryParams?: Record<string, number | string>,
) => {
  if (queryParams === undefined) return path;
  const searchParams = new URLSearchParams(
    Object.entries(queryParams).map(([key, value]) => [key, value.toString()]),
  );

  return `${path}?${searchParams.toString()}`;
};

type SetParams = {
  <const T extends string>(
    path: T,
    params: {
      path: PathParams<T>;
      query: Record<string, number | string>;
    },
  ): `${ReplacePath<T>}?${string}`;
  <const T extends string>(
    path: T,
    params: {
      path: PathParams<T>;
      query?: undefined;
    },
  ): ReplacePath<T>;
  <const T extends string>(
    path: T,
    params: {
      path?: undefined;
      query: Record<string, number | string>;
    },
  ): `${T}?${string}`;
  <const T extends string>(path: T, params?: undefined): T;
};

const setParams: SetParams = <const T extends string>(
  path: T,
  params?:
    | {
        path: PathParams<T>;
        query: Record<string, number | string>;
      }
    | {
        path: PathParams<T>;
        query?: undefined;
      }
    | {
        path?: undefined;
        query: Record<string, number | string>;
      },
) => {
  let result:
    | ReplacePath<T>
    | T
    | `${ReplacePath<T>}?${string}`
    | `${T}?${string}` = path;

  if (params?.path) {
    result = setPathParams(path, params.path);
  }

  if (params?.query) {
    result = setQueryParams(result, params.query);
  }

  return result;
};

const result = setParams('/users/{id}', {
  path: { id: 1 },
  query: { foo: 'bar' },
});
// ^? `/users/${string}?${string}`

Discussion