25
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Nextjsで型安全なルーティングを楽しむ

Posted at

はじめに

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で有効にすることで利用できます。

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 devnext 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コンポーネントのhrefuseRouterpushprefetchなどの型を上書きしています。

ディレクトリをもとにしたルーティングは__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つのパートに分けて紐解いて行きます。

ディレクトリ構成によって形を変える型

ディレクトリ構成に依存して変化する部分はStaticRoutesDynamicRoutesだけです。
StaticRoutesはとりうる静的なパスが、DynamicRoutesはとりうる動的なパスがそれぞれユニオンで記述されます。
//accounts/accounts/:idのようなパスにルーティングされている場合は以下のようになります。

type StaticRoutes = 
  | `/`
  | `/accounts`
type DynamicRoutes<T extends string = string> = 
  | `/accounts/${SafeSlug<T>}`

SafeSlug<T>の部分は後ほど解説しますが、動的パスの部分の確かさを検証しています。

ルーティング型を表現するための型

SearchOrHashはパスのうち、検索パラメータやハッシュの部分を表す型です。

type SearchOrHash = `?${string}` | `#${string}`

SuffixSearchOrHashに加えて空文字列をユニオンで取った型です。

type Suffix = '' | SearchOrHash

SafeSlugDynamicRoutesの動的な部分を検査する時に利用する型です。文字列がスラッシュを含まず、後尾に検索パラメータやハッシュを含まないことと空文字列ではないことを確認します。

type SafeSlug<S extends string> = S extends `${string}/${string}`
  ? never
  : S extends `${string}${SearchOrHash}`
  ? never
  : S extends ''
  ? never
  : S

CatchAllSlugOptionalCatchAllSlugSafeSlugと同じく、DynamicRoutesの動的な部分を検査する時に利用する型です。名前の通り動的ルーティングのうちCatch All SegmentOptional Catch All Optionsのような階層が不明な動的なパスを取る場合に使われます。

type DynamicRoutes<T extends string = string> = 
  | `/accounts/${CatchAllSlug<T>}`
  | `/users/${OptionalCatchAllSlug<T>}`

CatchAllSlugSafeSlugからスラッシュが含まれないことを確認する部分を排除し、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

は型に当てはめる文字列が動的なパスDynamicRoutesSuffixを加えた形式の文字列であれば自身を返し、そうでなければ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

先程まで見てきたRouteImplUrlObjectのどちらかを取る型を持ちます。
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版の機能ですが、大きなバグ等はなかったのでやむを得ない事情が出ない限りは有効にしてはいかがでしょうか。

25
16
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
25
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?