⛱️
TypeScriptで型安全なURL生成: パスとクエリパラメータの埋め込み
動機
- パス/クエリパラメータを埋め込むと型が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}` ぐらいほしい
- パスパラメータを型安全に指定したい(エディタで補完してほしい)
const setPathParams = (path, pathParams) =>
path.replace(/{(.*?)}/g, (_, key) => pathParams[key]);
// postIdではなく、正しくはuserIdなので型エラーになってほしい
setPathParams('/users/{userId}', { postId : 1 });
できたもの
クエリパラメータの埋め込みの実装
まず、簡単なクエリパラメータの方からやっていきます。
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