火曜日, 8月 26, 2025
火曜日, 8月 26, 2025
- Advertisment -
ホームニューステックニュースReact Router×TypeScriptで内部リンクを型で制限する

React Router×TypeScriptで内部リンクを型で制限する



概要

友人と進めている個人開発でReact Router(フレームワークモード)を使っています。プロジェクト的に管理画面も作っていく中で、React Routerのルーティング定義をどうするか話題に上がりました。所謂ルーティングパスを定数化するかどうか的な話です。そこで、定数化するのではなく型推論で解決するアプローチを取ってみました。

取り組んだことは以下の2点です。

  1. 直感的かつフレームワークの詳細を気にしないルーティング定義を可能にする
  2. aタグの薄いラッパーコンポーネントを用意し、型の制約を設ける

1に取り組んだ理由は型推論(実質型パズル)を頑張ることを考えた時にうまいこと定数値を用意したり、フレームワークの詳細からルーティング定義を分離したりしておく方が2に取り組みやすくなると考えたからです。フレームワークモードではファイルベースルーティングも利用できますが、今回は内部リンクのパスをどうにか作りたい関係で採用していません。(ファイルベースでも使えるかもしれないです。ご存知でしたら教えていただけると助かります)

1. 直感的かつフレームワークの詳細を気にしないルーティング定義を可能にする

見出しの通りの内容で、ルーティングを組み立てる関数を通してReact Routerのルーティング詳細を抽象化し、疎結合にします。

この手法には以下のメリット・デメリットがあります。
メリット

  • 論理的な構造になっているため、ルーティングの設定をなんとなくでできる
  • ReactRouterのドキュメントを読まなくても設定できる

デメリット

  • 何かあったときにフレームワークの調査が重くなる(普段読まない、気にしないため)

一方でルーティング関連は一度設定方法さえ知れば大きく変えないものでもありますし、フレームワーク側も一気にアプデかけることは滅多にないので良しとしています。以下、実装です。

routes.ts

const PATH_INDEX = "https://zenn.dev/"

interface GroupRoute {
  readonly prefix: string
  readonly layout?: `${string}.tsx`
  readonly routes: Route[]
}

interface Route {
  readonly path: string
  readonly file: `./routes/${string}.tsx`
}

function buildGroupRoute(group: GroupRoute): RouteConfigEntry[] {
  const routes = group.routes.map((value) =>
    value.path === PATH_INDEX ? index(value.file) : route(value.path, value.file),
  )
  const layoutRoutes = group.layout ? [layout(group.layout, routes)] : routes

  return group.prefix ? prefix(group.prefix, layoutRoutes) : layoutRoutes
}

const baseGroup = {
  prefix: '',
  routes: [
    { path: PATH_INDEX, file: './routes/_index.tsx' },
    { path: '/login', file: './routes/login/route.tsx' },
    { path: '/signup', file: './routes/signup/route.tsx' },
  ],
} as const satisfies GroupRoute

const adminGroup = {
  prefix: 'admin',
  layout: './routes/admin._layout.tsx',
  routes: [
    { path: PATH_INDEX, file: './routes/admin._index.tsx' },
    { path: '/users', file: './routes/admin.users/route.tsx' },
  ],
} as const satisfies GroupRoute

const groupRoutes = [baseGroup, adminGroup] as const satisfies GroupRoute[]
const routing: RouteConfig = groupRoutes.flatMap(buildGroupRoute)
export default routing

基本的な命名はReact Routerのメソッド名をそのまま使う形にしました。GroupRoute.layoutRoute.fileのテンプレートリテラルの制約はPJT独自のものです。単にstringとしても大丈夫です。

2. aタグの薄いラッパーコンポーネントを用意し、型の制約を設ける

これはルーティングのパスを定数化して利用しやすくするという目的のためにやったことです。定数化したものをhrefに与えて実装したパスにのみ遷移できるようにする実装パターンがあります。それを実現したかったのですが、enumのように用意するパスだとルーティング上あまり直感的でないと感じました。そこで先ほどのルーティングを維持した上で、コンポーネント利用時にパスをユニオン型で制限するアプローチに変更しました。型とコンポーネントの実装について、順に説明します。

型の実装

以下ユニオン型の実装です。

routes.ts

type CombinePrefixP extends string, Path extends string> = Path extends "https://zenn.dev/"
  ? P extends ''
    ? "https://zenn.dev/"
    : `/${P}`
  : Path extends `/${infer Rest}`
    ? P extends ''
      ? `/${Rest}`
      : `/${P}/${Rest}`
    : never

type PathsT extends readonly GroupRoute[]> = T[number] extends infer Item
  ? Item extends GroupRoute
    ? CombinePrefixItem['prefix'], Item['routes'][number]['path']>
    : never
  : never

export type InternalPath = Pathstypeof groupRoutes>

型パズルはそんなに得意ではないので、生成AIと協力して実装しました。まずはルーティングのPrefixを処理して一つのパスにする型CombinePrefixを実装しました。次にPaths型を実装し、ユニオン型にしました。これだけだと定数の値を型推論に利用できないので、baseGroupadminGroupの定義の際にconstアサーションを使っています。constアサーションを入れても尚GroupRoute型のIDEによる補完と型の制約は使いたかったのでsatisfiesを使っています。

コンポーネントの実装

早速ですが、実装です。

anchorLink.tsx

import { PropsWithChildren } from 'react'
import { Link } from 'react-router'
import { InternalPath } from '../routes'

interface BaseProps {
  className?: string
}

type ExternalPath = `https://${string}`

interface ExternalLinkProps extends BaseProps {
  to: ExternalPath
}

function ExternalLink({ to, children, className }: PropsWithChildrenExternalLinkProps>) {
  return (
    a href={to} className={className} target='_blank' rel='noopener noreferrer'>
      {children}
    a>
  )
}

interface InternalLinkProps extends BaseProps {
  to: InternalPath
}

function InternalLink({ to, children, className }: PropsWithChildrenInternalLinkProps>) {
  return (
    Link to={to} className={className}>
      {children}
    Link>
  )
}

function isExternalPath(to: string): to is ExternalPath {
  return /^https:\/\//.test(to)
}

type AnchorLinkProps = PropsWithChildren{
  to: InternalPath | ExternalPath
  className?: string
}>

export function AnchorLink({ to, className, children }: AnchorLinkProps) {
  return isExternalPath(to) ? (
    ExternalLink to={to} className={className}>
      {children}
    ExternalLink>
  ) : (
    InternalLink to={to} className={className}>
      {children}
    InternalLink>
  )
}

サイト内部のリンクと外部のリンクで与えるパスは異なるため、InternalLinkとExternalLinkで小さくコンポーネントを用意し、AnchorLink(aタグの軽いラッパー)を実装しました。内部リンクを使う場合はInternalPathのユニオン型にあるもののみ指定し、そうでない場合はhttpsのリンクを与えます。

また、サイト内部の遷移ではReactRouterの用意したLinkコンポーネントを使いました。Next.js同様に内部リンクの場合に限りますが、パフォーマンス的に優れているとのことだったからです。

この後やることとして、既存のaタグを禁止あるいは警告するlintのルール作成があります。biomeを使っていて初めてやることになるので、後日その記事を書きます。多分。
良い記事があったので、こちらを参考に記述しました!

https://zenn.dev/bmth/articles/biomejs-gritql-plugin

最後に

今回、よく見るenum式アプローチではない手法を取ってみました。今は作りきった気持ちで一杯でダニング・クルーガー効果が出ていますが、今後進めていくと別の結論が出るかもしれません。
お忙しい中ご覧いただき、ありがとうございました!

参考文献

  • React Routerのフレームワークモードのルーティング
  • テンプレートリテラル(文字列リテラル)
  • constアサーション



Source link

Views: 0

RELATED ARTICLES

返事を書く

あなたのコメントを入力してください。
ここにあなたの名前を入力してください

- Advertisment -