🐼

Next.jsのルーティングにTypeScriptで型をつけたい

2020/12/30に公開

動機と目的

普段、Next.jsでアプリケーションを開発しています。当初は Next.js にも TypeScript にも慣れていなかったため、ページのパスを定数で定義し、Link コンポーネントで呼び出していました。

// constants/path.ts
export const TOP_PATH = '/'
export const USERS_PATH = '/users'
// ...
<Link href={`${USERS_PATH}/${userId}`}>
  <a>ユーザー詳細へ</a>
</Link>

幸い大規模なアプリケーションではないため、ページ数は複雑なパスはなく、特に違和感はありませんでした。

しかし、Srush というプロダクトでご一緒した @kazuma1989 さんが、以下のようなオブジェクトを作成されているのを見て、TS から型安全にリンクを作成するアプローチがあることを知りました。

const path = Paths['/users/:userId/']({ userId: 'foo' })

console.log(path) // "/users/foo/"

そこで、Next.js のパス生成でも同様のことができそうだと考え、Template Literal Types を使って実装してみました。

その過程をツイートしたところ、意外と反響があったので、TypeScript の自由研究の成果としてまとめられるところまで進めてみました。

成果物は TypeScript の Playground で公開しています。この記事の以下の部分は、成果物の解説です。Next.js の Dynamic Routes の解説は省略します。

なお、本記事内では'/users/[user_id]/blogs/[blog_id]'というパスに対して、Slug はusers, user_id, blogs. blog_idを指し、パラメータは user_id, blog_idを指すこととします。

最終目標は '/users/[user_id]/blogs/[blog_id]' というパスがあるとき、引数{ path: '/users/[user_id]/blogs/[blog_id]', params: {user_id: 'foo', blog_id: 'bar' }} を与えることで "/users/foo/blogs/bar" の文字列が得られる getPath 関数を作ることです。

const path = getPath({
  path: '/users/[user_id]/blogs/[blog_id]',
  params: { user_id: 'foo', blog_id: 'bar' }
})

console.log(path) // "/users/foo/blogs/bar"

Template Literal Types で パスから URL Slugs を抜きだす

[] で囲われたパラメータのみを String Literal Types に変換する Params 型を実装します。

type A<T> = T extends `/${infer U}` ? U  : never
type B<T> = T extends `${infer U}/${infer S}` ? U | B<S> : T
type C<T> = T extends `[${infer U}]` ? U : never

const path = '/users/[user_id]/blogs/[blog_id]'
type A0 = A<typeof path>         // "users/[user_id]/blogs/[blog_id]"
type B0 = B<A<typeof path>>      // "users" | "[user_id]" | "blogs" | "[blog_id]"
type C0 = C<B<A<typeof path>>>   // "user_id" | "blog_id"

type Params<T> = C<B<A<T>>>

type A で1文字目の/を削除します。type B で/で全ての URL Slug を String Literal Types とします。次に、type C で[]で囲われたパラメータのみを取得します。

Params 型の実行結果は以下の通りです。

type Params0 = C<B<A<'/users'>>>                           // never
type Params1 = C<B<A<'/[users]'>>>                         // "users"
type Params2 = C<B<A<'/[users]/[id]'>>>                    // "users" | "id"
type Params3 = C<B<A<'/users/[id]'>>>                      // "id"
type Params4 = C<B<A<'/users/[user_id]/blogs/[blog_id]'>>> // "user_id" | "blog_id"
type Params5 = C<B<A<'/users/settings'>>>                  // never

これでパラメータのみを取得できました。ただし、本番環境で用いる場合は A, B, C という型名は避けて、何らかの名付けをする方が良いと思います。

パスを集約するオブジェクトを作成する

パスは Paths オブジェクトにまとめます。

また、この Paths オブジェクトの key、value の値を String Literal Types で型とします。

const Paths = {
  help: '/help',
  book: '/books/[id]',
  userBlog: '/users/[user_id]/blogs/[blog_id]',
} as const

type PathKey = keyof typeof Paths
type Path = typeof Paths[PathKey]

以後、ページが増えれば、ここにパスを追記していきます。

パラメータに値を当てはめるgetPath関数を作成する

最後に、getPath 関数を作成します。先に作った Params 型を利用します。

type ParamKeys<T extends Path> = Params<T>
type PathParams<T extends Path> = {
  path: T;
  // パラメータは string または number としています
  params?: {[K in ParamKeys<T>]: string | number};
}
// PathParams 型を使って、パラメータが不要な場合は params を必須にします
type Args<T extends Path> = ParamKeys<T> extends never
    ? PathParams<T> : Required<PathParams<T>>

function getPath<T extends Path>({ path, params }: Args<T> ) {
  if (!params) {
    return path
  }

  return path.split('/').map(str => {
    const match = str.match(/\[(.*?)\]/)
    if (match) {
      const key = match[0]
      const trimmed = key.substring(1, key.length - 1) as ParamKeys<typeof path>
      return params[trimmed]
    }

    return str
  }).join('/')
}

引数に間違ったパラメータを渡したり、パラメータが足りないと型レベルでエラーになります。

// Paths オブジェクトを再掲します
const Paths = {
  help: '/help',
  book: '/books/[id]',
  userBlog: '/users/[user_id]/blogs/[blog_id]',
} as const

// "/help"
getPath({ path: Paths.help })

getPath({ path: Paths.book, params: { id: 'works' }})      // "/books/works"
getPath({ path: Paths.book })                              // error
getPath({ path: Paths.book, params: { name: 'error' }})    // error

// "/users/foo/blogs/bar"
getPath({ path: Paths.userBlog, params: { user_id: 'foo', blog_id: 'bar' }})
// error
getPath({ path: Paths.userBlog, params: { id: 'foo', error: 'bar' }})

getPathを適用

また、パラメータが足りない場合は、型補完が効きます。

getPath({ path: Paths.userBlog, params: ) // params が不足している

getPathの型補完

これで型補完が効くパス取得関数 getPath を作成できました!

LinkコンポーネントをラップするCustomLinkコンポーネントを作成する

ここまででも良いのですが、せっかくなので Next.js の Link コンポーネントをラップする CustomLink コンポーネントを作成します。

import React from 'react'
import Link, { LinkProps } from 'next/link'
import qs from 'query-string'

import { Args, Path, createPath } from '~/src/constants'

type Props<T extends Path> = {
  query?: { [key: string]: string | number | string[] };
  hash?: string;
} & Args<T> & Omit<LinkProps, 'href'>

const CustomLink: React.FC<Props<Path>> = (props) => {
  const path = createPath({ path: props.path, params: props.params })
  const query = props.query ? `?${qs.stringify(props.query)}` : ''
  const hash = props.hash ? `#${props.hash}` : ''
  const href = path + query + hash

  return (
    <Link href={href} {...props}>
      <a>{props.children}</a>
    </Link>
  )
}

export default CustomLink

これで query, hash にも対応した内部リンクのコンポーネントを作成できました。

なお、type C と getPath 関数を少し書き換えると、React Router の /:slug という書き方にも対応できると思います。

対応できていないこと

仕事で関わっているアプリケーションだとここまででちゃんと使えるコンポーネントとなりました。しかし、[...slug]という書き方には対応していないので、もし必要なら pathpida を使うと良いかもしれません。

参考

コード全文

最後にコード全文を掲載します。

// src/constants/path.ts
export const Paths = {
  top: '/',
  help: '/help',
  book: '/books/[id]',
  userBlog: '/users/[user_id]/blogs/[blog_id]',
  'api/posts': '/api/posts',
} as const

export type PathKey = keyof typeof Paths
export type Path = typeof Paths[PathKey]

type WithoutSlash<T> = T extends `/${infer U}` ? U : never
type Resource<T> = T extends `${infer U}/${infer S}` ? U | Resource<S> : T
type DynamicRoute<T> = T extends `[${infer U}]` ? U : never

type Params<T> = DynamicRoute<Resource<WithoutSlash<T>>>
type ParamKeys<T extends Path> = Params<T>

type PathParams<T extends Path> = {
  path: T
  params?: { [K in ParamKeys<T>]: string | number }
}
export type Args<T extends Path> = ParamKeys<T> extends never ? PathParams<T> : Required<PathParams<T>>

export function createPath<T extends Path>({ path, params }: Args<T>) {
  if (!params) {
    return path
  }

  return path
    .split('/')
    .map((str) => {
      const match = str.match(/\[(.*?)\]/)
      if (match) {
        const key = match[0]
        const trimmed = key.substring(1, key.length - 1) as ParamKeys<typeof path>
        return params[trimmed]
      }
      return str
    })
    .join('/')
}

Discussion