BC チームでエンジニアをしている id:d-kimuson です
11月にリリースされた TypeScript 4.9 から satisfies operator が追加されました。satisfies operator が追加されたことで 「React Router でのナビゲーションを型安全にする」がやりやすくなったのでやってみました
この記事で紹介するコードは TS Playground で試すことができます
React Router v6.4 からオブジェクト形式でルーティングをかけるようになり、ルーティング宣言から型を拾いやすくなった
React Router v6.4 から createXXXRouter のAPIが追加され、コンポーネントではなく、プレーンオブジェクトでルーティングを書けるようになりました
import { createBrowserRouter } from "react-router-dom" const router = createBrowserRouter([ { path: "/", element: <HomePage />, }, ])
な形式でルーティングを宣言できます
以前からある
<BrowserRouter> <Routes> <Route path="/" component={HomePage} /> </Routes> </BrowserRouter>
なコンポーネント形式のルーティングでは難しかった、「宣言から型情報を読み取る」ことができるようになりました
ルーティングの宣言に型の制約を課したいが、具体な型に解決させたい
ルーティングの宣言から型情報を拾えるようになったので、良い感じに拾って型安全なナビゲーションを実現したいなと考えます
しかし
- 宣言に型の制約を課しつつ
- 型自体は宣言から具体な型に解決させる
はちょっと実現が面倒です
ルーティングオブジェクトの宣言に RouteObject[]
型の制約を課すために形注釈をつけると
import type { RouteObject } from "react-router-dom" const routes: RouteObject[] = [ { path: "/", element: <HomePage />, }, ]
制約は課すことができますが、routes は RouteObject[]
型に解決されてしまうので、具体的なルーティング(/
) を型情報から拾うことができません
宣言に合わせた型を拾いたいなら注釈をつけずに as const
を使うのが有効です
const routes = [ { path: "/", element: <HomePage />, }, ] as const
ただし、今度は routes に RouteObject[]
な制約をかけられていません
結果、補完が効かなくなったり宣言ではなく使用箇所での型エラーになってしまったりで望ましくありません
satisfies operator
この問題が satisfies operator で解決して、「制約を書けるが具体な型に解決させる」ができるようになりました
satisfies operator は型の制約をかしますが、解決される型には影響を与えません
したがって
import type { ReadonlyDeep } from "type-fest" // as const すると readonly 化してしまうので type RoutesDef = ReadonlyArray<ReadonlyDeep<RouteObject>> const routes = [ { path: "/", element: <HomePage />, }, ] as const satisfies RoutesDef
で宣言することで、RouteObject[]
な制約でルーティングを宣言しつつ、routes には宣言通りの型に解決させることができるようになりました
上の routes 変数は実際に
const routes2: readonly [{ readonly path: "/"; readonly element: JSX.Element; }]
型に解決され、制約に違反すると型エラーが出ます
※ satisfies がないと実現できないというわけではなく、Vue 関連のエコシステムでよく使われている defineXXX
のパターンでも一応同じことは達成できましたが、satiesfies operator で実現しやすくなりました
遷移に制約をつける
routes
を具体な型に解決させられるようになったので、型演算を通じて型安全なナビゲーションを実現できます
サンプルとして、以下のルーティングの宣言を用意します
const routes = [ { path: "/", element: <HomePage />, }, { path: "/nests", element: ( <div> <h2>Nests route</h2> <Nav /> </div> ), children: [ { path: ":nestId", element: ( <div> <h2>nests 20</h2> <Nav /> </div> ), }, ], }, ] as const satisfies RoutesDef
typeof routes を扱いやすい型に整形する
ルーティングのネストは children で書かれていて使いにくいので、まずは使いやすい型に変換していきます
type RouteConfig<T extends RoutesDef, U = ToRouteUnion<T>> = AsObjectShape< U extends { path: string } ? U : { path: string } > /** * @desc children のネストを解決して Union にする * { * readonly path: "/"; * readonly element: JSX.Element; * } | { * readonly path: "/example"; * readonly element: JSX.Element; * } | { * readonly path: "/nests"; * readonly element: JSX.Element; * readonly children: readonly [...]; * } | { * path: "/nests/:nestId"; * } */ type ToRouteUnion<T extends RoutesDef> = T extends ReadonlyArray<infer I> ? MergeChild<I> : never /** * @desc 使いやすい Object 形式に整形 * { * "/example": { * path: "/example"; * }; * "/nests": { * path: "/nests"; * }; * "/": { * path: "/"; * }; * "/nests/:nestId": { * path: "/nests/:nestId"; * } & { * params: { * nestId: string; * }; * }; * } */ type AsObjectShape<T extends { path: string }> = { [K in T["path"]]: { path: K } & (ParsePathParams<K> extends infer Params ? keyof Params extends never ? {} : { params: Params } : never) } type MergeChild<T> = T extends { path: string children: ReadonlyArray<infer Children extends { path: string }> } ? | (T extends { element: JSX.Element } ? T : never) | MergeChild< { path: `${T["path"]}/${Children["path"]}` } & (Children extends { children: any } ? { children: Children["children"] } : {}) > : T /** * @desc リテラルなルーティング文字列からパスパラメタを抽出する * @example ParsePathParams<'/nests/:nestId'> = { nestId: string } */ type ParsePathParams<T extends string> = [T] extends [`${string}:${infer I1}`] ? I1 extends `${infer Param}/${infer I2}` ? Required<{ [K in Param]: string } & ParsePathParams<I2>> : { [K in I1]: string } : {}
こういうパズルを組みます
ここでは詳細な説明はしませんが
- children にネストしていたルーティングをマージして
- それぞれのパスからパスパラメタを抽出して
- 使いやすい型に整形
をしています
typeof routes
を渡してあげると
export type RouteConf = RouteConfig<typeof routes>
これは以下に解決されます
type RouteConf = { "/": { path: "/"; }; "/nests": { path: "/nests"; }; "/nests/:nestId": { path: "/nests/:nestId"; } & { params: { nestId: string; }; }; }
使いやすい型を抽出することができました
型安全に遷移先のリンクを生成する
使いやすい型が手に入ったので、これを使って型安全にパスを生成できる utility を作っていきます
export const pagePath = <T extends keyof RouteConf>( path: T, ...args: RouteConf[T] extends { params: any } ? [RouteConf[T]['params']] : [] ): string => { const [params] = args as [Record<string, string> | undefined] return params === undefined ? path : Object.entries(params).reduce( (s: string, [key, value]) => s.replace(`:${key}`, value), path ) }
これで pagePath 関数を通すことで型安全に遷移先のルーティングを書くことができるようになりました
Link タグの to には pagePath の関数でパスを設定します
<ul> <li> <Link to={pagePath('/')}>Home</Link> </li> <li> <Link to={pagePath('/example')}>Example</Link> </li> <li> <Link to={pagePath('/nests/:nestId', { nestId: '20' })} > Nests 20 </Link> </li> </ul>
useNavigate からの遷移でも同様に
const navigate = useNavigate() const onClick = () => { navigate(pagePath('/nests/:nestId', { nestId: '20' })) }
とすることで、型安全なナビゲーションを実現することができました
その他の型安全ルーティング
ということで、React Router をそのまま使いながら型安全なナビゲーションを実現できましたが、ルーティングの宣言自体型を拾うことを前提に作られてないので対応するのがそこそこ大変でした
また、クエリパラメタについても React Router のルーティング宣言にはクエリの型を書くようなインタフェースがないので拾うことができません
したがって、本格的に型安全を目指したい場合は
- 型情報を拾うことを前提にしたインタフェースでルーティングを宣言し、React Router に渡せる routes を吐き出すようなアプローチ
- 型情報を拾うことを前提にしたルーティングライブラリ
- Rocon 等
を使うのが良いと思います
一方、ルーティングのインタフェースを変えてしまうと React Router 等のメジャーなライブラリと比較してメンテナスが滞ったときや、バージョンアップ時のマイグレーションがつらくなる側面はあります
今回紹介した「React Router のインタフェースに乗っかりながら、可能な範囲で型安全性を保証するアプローチ」の場合、インタフェース変更時のマイグレーションも公式のマイグレーションに乗っかれば良いだけなので無難な選択肢にはなるのかなと思います
できればアプリケーションコードにはこの辺りを入れたくないので、願わくば、公式や著名なところからライブラリとして出てくれると嬉しいんですが...
まとめ
satiesfies operator と React Router v6.4 のオブジェクト形式のルーティングで標準的な記法をそのまま使って型安全なナビゲーションを実現することができるようになりました
また、実際に最低限の実装例を紹介しました
それでは良い型安全ライフを!