はじめに
NextjsでApp Routerを用いた開発を行なっていますか?
App RouterはReact Canariesから提供されるサーバー側コンポーネントとクライアント側コンポーネントを利用したコンポーネント単位でレンダリング方法を制御する機能や、直感的なルーティング、そのほかにも豊富な機能を持つNextjsの新し機能です。
そんなApp Routerですがアプリケーション内のルーティングに対して型を付与するStatically Typed Links
という機能がバージョン13.2からbeta版で提供がされています。
Tanstack Routerのregistering-router-typesやVue Routerのtyped-routesと似たような機能です(何もbeta版です)。
この記事ではそんなStatically Typed Links
の使い方について紹介します。
Statically Typed Linksを有効化する
Statically Typed Links
はbeta版の機能なのでnext.config.js
で有効にすることで利用できます。
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
typedRoutes: true,
},
};
module.exports = nextConfig;
TypeScriptを用いた型提供なので、TypeScriptを用いたアプリケーションのみで有効なことに注意してください。
Statically Typed Linksによる型付け
Stacically Typed Linksを有効にするとNextjsはApp Routerのディレクトリ構成をもとにルーティングに関する様々な型を生成してくれます。
型はnext dev
とnext build
を実行するタイミングで.next/types/link.d.ts
に作られます(next dev
は実行中であればルーティングの変更に合わせて自動でファイルを書き換えます)。
create-next-app
から生成したばかりの状態では以下のように生成されます。
// Type definitions for Next.js routes
/**
* Internal types used by the Next.js router and Link component.
* These types are not meant to be used directly.
* @internal
*/
declare namespace __next_route_internal_types__ {
type SearchOrHash = `?${string}` | `#${string}`
type Suffix = '' | SearchOrHash
type SafeSlug<S extends string> = S extends `${string}/${string}`
? never
: S extends `${string}${SearchOrHash}`
? never
: S extends ''
? never
: S
type CatchAllSlug<S extends string> = S extends `${string}${SearchOrHash}`
? never
: S extends ''
? never
: S
type OptionalCatchAllSlug<S extends string> =
S extends `${string}${SearchOrHash}` ? never : S
type StaticRoutes =
| `/`
type DynamicRoutes<T extends string = string> = never
type RouteImpl<T> =
| StaticRoutes
| `${StaticRoutes}${SearchOrHash}`
| (T extends `${DynamicRoutes<infer _>}${Suffix}` ? T : never)
}
declare module 'next' {
export { default } from 'next/types'
export * from 'next/types'
export type Route<T extends string = string> =
__next_route_internal_types__.RouteImpl<T>
}
declare module 'next/link' {
import type { LinkProps as OriginalLinkProps } from 'next/dist/client/link'
import type { AnchorHTMLAttributes } from 'react'
import type { UrlObject } from 'url'
type LinkRestProps = Omit<
Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof OriginalLinkProps> &
OriginalLinkProps,
'href'
>
export type LinkProps<T> = LinkRestProps & {
/**
* The path or URL to navigate to. This is the only required prop. It can also be an object.
* @see https://nextjs.org/docs/api-reference/next/link
*/
href: __next_route_internal_types__.RouteImpl<T> | UrlObject
}
export default function Link<RouteType>(props: LinkProps<RouteType>): JSX.Element
}
declare module 'next/navigation' {
export * from 'next/dist/client/components/navigation'
import type { NavigateOptions, AppRouterInstance as OriginalAppRouterInstance } from 'next/dist/shared/lib/app-router-context'
interface AppRouterInstance extends OriginalAppRouterInstance {
/**
* Navigate to the provided href.
* Pushes a new history entry.
*/
push<RouteType>(href: __next_route_internal_types__.RouteImpl<RouteType>, options?: NavigateOptions): void
/**
* Navigate to the provided href.
* Replaces the current history entry.
*/
replace<RouteType>(href: __next_route_internal_types__.RouteImpl<RouteType>, options?: NavigateOptions): void
/**
* Prefetch the provided href.
*/
prefetch<RouteType>(href: __next_route_internal_types__.RouteImpl<RouteType>): void
}
export declare function useRouter(): AppRouterInstance;
}
ディレクトリ構成をもとにしたルーティング可能なパスを表す文字列の型を作成し、それをもとにNextjsが持つルーティングに関するAPIに対して型付けをしています。よく使うものだとLink
コンポーネントのhref
、useRouter
のpush
やprefetch
などの型を上書きしています。
ディレクトリをもとにしたルーティングは__next_route_internal_types__
で組み上げています。
declare namespace __next_route_internal_types__ {
type SearchOrHash = `?${string}` | `#${string}`
type Suffix = '' | SearchOrHash
type SafeSlug<S extends string> = S extends `${string}/${string}`
? never
: S extends `${string}${SearchOrHash}`
? never
: S extends ''
? never
: S
type CatchAllSlug<S extends string> = S extends `${string}${SearchOrHash}`
? never
: S extends ''
? never
: S
type OptionalCatchAllSlug<S extends string> =
S extends `${string}${SearchOrHash}` ? never : S
type StaticRoutes =
| `/`
type DynamicRoutes<T extends string = string> = never
type RouteImpl<T> =
| StaticRoutes
| `${StaticRoutes}${SearchOrHash}`
| (T extends `${DynamicRoutes<infer _>}${Suffix}` ? T : never)
}
この部分が型安全なルーティングを組み上げる根幹なので3つのパートに分けて紐解いて行きます。
ディレクトリ構成によって形を変える型
ディレクトリ構成に依存して変化する部分はStaticRoutes
とDynamicRoutes
だけです。
StaticRoutes
はとりうる静的なパスが、DynamicRoutes
はとりうる動的なパスがそれぞれユニオンで記述されます。
/
と/accounts
と/accounts/:id
のようなパスにルーティングされている場合は以下のようになります。
type StaticRoutes =
| `/`
| `/accounts`
type DynamicRoutes<T extends string = string> =
| `/accounts/${SafeSlug<T>}`
SafeSlug<T>
の部分は後ほど解説しますが、動的パスの部分の確かさを検証しています。
ルーティング型を表現するための型
SearchOrHash
はパスのうち、検索パラメータやハッシュの部分を表す型です。
type SearchOrHash = `?${string}` | `#${string}`
Suffix
はSearchOrHash
に加えて空文字列をユニオンで取った型です。
type Suffix = '' | SearchOrHash
SafeSlug
はDynamicRoutes
の動的な部分を検査する時に利用する型です。文字列がスラッシュを含まず、後尾に検索パラメータやハッシュを含まないことと空文字列ではないことを確認します。
type SafeSlug<S extends string> = S extends `${string}/${string}`
? never
: S extends `${string}${SearchOrHash}`
? never
: S extends ''
? never
: S
CatchAllSlug
とOptionalCatchAllSlug
はSafeSlug
と同じく、DynamicRoutes
の動的な部分を検査する時に利用する型です。名前の通り動的ルーティングのうちCatch All SegmentとOptional Catch All Optionsのような階層が不明な動的なパスを取る場合に使われます。
type DynamicRoutes<T extends string = string> =
| `/accounts/${CatchAllSlug<T>}`
| `/users/${OptionalCatchAllSlug<T>}`
CatchAllSlug
はSafeSlug
からスラッシュが含まれないことを確認する部分を排除し、OptionalCatchAllSlug
はそれに加えて空文字列ではないことを確認する部分を排除した型となっています。
type CatchAllSlug<S extends string> = S extends `${string}${SearchOrHash}`
? never
: S extends ''
? never
: S
type OptionalCatchAllSlug<S extends string> =
S extends `${string}${SearchOrHash}` ? never : S
ルーティングパス
これまでみてきた型を組み合わせて実際にルーティングが提供するパスをRouteImpl
で表現しています。
type RouteImpl<T> =
| StaticRoutes
| `${StaticRoutes}${SearchOrHash}`
| (T extends `${DynamicRoutes<infer _>}${Suffix}` ? T : never)
静的なパスStaticRoutes
と静的なパスStaticRoutes
に検索パラメータやハッシュSearchOrHash
を足したもので静的なルーティングパスを表現しています。一見${StaticRoutes}${Suffix}
で良さそうな気もしますが、このように表現した場合パスの入力補完が効かないため分けて記述されています(こちらのコメントを参照)。
最後の
T extends `${DynamicRoutes<infer _>}${Suffix}` ? T : never
は型に当てはめる文字列が動的なパスDynamicRoutes
にSuffix
を加えた形式の文字列であれば自身を返し、そうでなければnever
を返すことで擬似的にルーティングパスを表現しています。DynamicRoutes
に渡すT
はパス文字列ではなく動的パラメータであることに注意してください。静的なルーティングのようにシンプルなリテラル型で表現できないので自動保管は効きません。
ルーティングされた機能
先程まで見てきたRouteImpl
を使ってNextjsの機能が型付けされていきます。
ルーティング可能な文字列としてnext
からインポートできるRoute
という型で定義されています。
export type Route<T extends string = string> =
__next_route_internal_types__.RouteImpl<T>
そして、next/link
から利用するLink
コンポーネントのhref
は以下のように定義されています。
href: __next_route_internal_types__.RouteImpl<T> | UrlObject
先程まで見てきたRouteImpl
とUrlObject
のどちらかを取る型を持ちます。
UrlObject
を利用可能にすると型の安全性が担保できないので、生成した型のみでルーティングさせるコンポーネントを作ってみます。
import { Route } from 'next';
import Link from 'next/link';
import { ReactNode } from 'react';
export const Anchor = <T extends string>({
href,
children,
}: {
href: Route<T>;
children: ReactNode;
}) => {
return (
<Link href={href}>
{children}
</Link>
);
};
Route
が提供されているので、簡単に型安全なルーティングパスに則った型を用いたコンポーネントを作れます。
useRouter
のそれぞれのメソッドにもパスを渡す引数に対してルーティングに基づいた型が定義されています。
href: __next_route_internal_types__.RouteImpl<RouteType>
ディレクトリによってルーティングしたパスが/
、/accounts
、/accounts/:id
の場合は型をチェックは以下のような結果になります。
// OK
<Link href="/" />
<Link href="/accounts" />
<Link href="/accounts/1" />
<Link href={`/accounts/${id}`} />
<Link href={('/accounts/' + id) as Route} />
navigate.push('/');
navigate.replace('/accounts');
// NG
<Link href="/aboot" />
<Link href={'/accounts/' + id} />
navigate.prefetch('/accounts' + id);
おわりに
この記事ではStatically Typed Links
を有効にしてNextjsで型安全なルーティングを行う方法を紹介しました。
Statically Typed Links
ではこれまで型として検査できなかった部分が検査可能になり、より安全で快適なアプリケーションを構築できるようになります。beta版の機能ですが、大きなバグ等はなかったのでやむを得ない事情が出ない限りは有効にしてはいかがでしょうか。