Next.jsのルーティングにTypeScriptで型をつけたい
動機と目的
普段、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({ path: Paths.userBlog, params: ) // params が不足している
これで型補完が効くパス取得関数 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 を使うと良いかもしれません。
参考
- @takepepe さんの著書「TypeScript CompilerAPI - 創出の落書帳 -」の第4章で Next.js の型安全なルーティングをするアプローチが紹介されています。
- 設定0行でNext.jsとNuxt.jsの内部リンクを型安全に取得できる最強ライブラリ「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