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 文字列を元に、プロパティの型推論が効く
- query プロパティで query parameter の key と value を指定できる
つまり、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 を想定しているのか一目瞭然であるので、「仕様をコードで表現」することができる。
「よりシンプル」に、しかし「型安全」に、更に「仕様を実装に落とし込められる」ようなものがないか、常に模索しながらコードを書いてい着たいと思った。