Vue Routerに型をもたらすVue Routiderをつくった

題名の通り、**Vue Routider**というVue Routerに型をつけるラッパーライブラリをつくりました！:hand_splayed:
8月13日から作り始めたので今日でちょうど3週間です。
この記事の下のほうにありますが、制約事項がいくつか存在しています。ただほとんどは今後対応予定です。

注意事項として、Vue3用のVue Router v4でしか動かなくて、Vue2用のVue Router v3では動きません。
ほぼ型情報を付与するだけのライブラリなのでminify時に1.4kB程度と小さめになっています[^1]。

https://vue-routider.sapphi.red/

# 経緯
「[究極のReact向けルーターライブラリ「Rocon」を作った](https://blog.uhy.ooo/entry/2020-08-10/rocon-alpha/)」という記事を読んでVueでもルーターに型がほしいと感じました。
しかし、Vuexには[direct-vuex](https://github.com/paroi-tech/direct-vuex)や[typed-vuex](https://nuxt-typed-vuex.roe.dev/)などの型をつけるライブラリが存在していますが、Vue Routerには調べたところでは一つも見つからなかったのでちょうどいい機会なので作ってみようと思って作りました。

作るにあたって、Vue Routerから利用方法が大きく変わるのは避けたかったので、できる限り同じような書き方ができるようにしました。

# 使い方
ルーター部分に関しては基本的には配列でルートを宣言していた箇所をオブジェクトにするだけです。

```ts:router/index.ts
import { createWebHistory } from 'vue-router'
import { createRoutider, createPath, createPaths, createQueries } from 'vue-routider'

const { router, useRouter, useRoute } = createRoutider({
  history: createWebHistory(),
  routes: {
    // Vue Routerのようにルートの配列を渡すのではなくオブジェクトで渡します
    Index: {
      path: '/', // パラメータが含まれない場合は普通の文字列が使えます
      component: /* コンポーネント */
    },
    Item: {
      path: createPath`/items/${'id'}`, // パラメータを含む場合はパラメータ名だけ${}でくくって、先頭にcreatePathをつけて宣言します
      component: /* コンポーネント */
    },
    UserItem: {
      path: createPath`/users/${'userId'}/${'id'}`,
      query: createQueries('order'), // クエリパラメータも対応していてこのように書きます
      component: /* コンポーネント */
    },
    Users: {
      // aliasの宣言はcreatePathsを利用します(パラメータが含まれない場合は文字列の配列が使えます)
      path: createPaths(
        createPath`/users/${'id'}`,
        createPath`/u/${'id'}` // ここで例えばcreatePath`/u/${'i'}`のようにパラメータが一致していないものを渡すと型エラーが発生します
      ),
      component: /* コンポーネント */
    }
  }
})

export default router
export { useRouter, useRoute }
```

ほかの箇所もVue Routerとほぼ同じ使い方です。

```ts:main.ts
import { createApp } from 'vue'
import router from './router'

const app = createApp(App)
app.use(router)
app.mount('#app')
```

```ts:pages/Item.vue
import { defineComponent } from 'vue'
import { useRoute, useRouter } from '../router'

export default defineComponent({
  setup() {
    const route = useRoute('Item') // 宣言したときのオブジェクトのキーで指定します
    // ここのroute.paramsの型はしっかり{ id: string | string[] }になります

    const router = useRouter()
    router.push({ name: 'Index' })
    // ここでは{ name: 'Index', params: { id: '0' } }のような指定をすると型で怒られます
    // router.push('Index') の書き方は今のところ対応していません。パラメータのないルートに対しては受け取れるようにしてもよさそうですね。
  }
})
```
ここではComposition APIで書いていますが、Options APIに関しては`$route`をどうするのがよさそうか思いついていないので今のところ対応していません[^i39]。

ここまで見たように、`router/index.ts`は似たような書き方、`main.ts`とそれぞれの`.vue`についてはほぼ同じ書き方で型がつくようになっています。
型でチェックできそうなものは基本的にほぼすべての箇所でできるようになっているはずです。
ただし、ナビゲーションガードに関しては次のように書く必要があります。これは実行時まで型情報が定まらないためですが、Type Guard関数を提供することで、それを利用して型が絞れるようにしてあります[^guard]。

```ts
router.beforeEach((to, from, next) => {
  // ここではtoがどこかが絞り込まれていないため、to.paramsは存在しないようになっています
  if (router.isRouteName(to, 'Item')) {
    // ここではto.paramsがItemルートのパラメータの型になります
  }

  next(true)
})
```

ネストされているルートに関しても以下のように宣言できます。同じように配列をオブジェクトに変えるという感じです。

```ts
      Users: {
        path: '/users',
        component: /* コンポーネント */,
        children: {
          User: {
            path: createPath`${'userId'}`,
            component: /* コンポーネント */,
            children: {
              UserDetail: {
                path: 'detail',
                component: /* コンポーネント */
              },
              UserItem: {
                path: createPath`${'id'}`,
                component: /* コンポーネント */
              }
            }
          }
        }
      },
```

## エディタの補完
型がついているのでもちろん下のように補完の表示がされます。:sunglasses:(画面はVSCodeのものです。)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/388834/e1ad75a8-45cb-1113-c787-2d569a9023ed.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/388834/4d2960ad-e5f7-d74e-75b0-2062463a40dc.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/388834/4252483e-5cc8-5a42-98c5-f1fbc0f18636.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/388834/3712aac2-5c91-b4ca-c5e0-eff32caa84b1.png)
beforeEnterの場合は`createRoute`で括る必要があります[^be]。

# 仕組み
## 型の変換の流れ
Vue Routerをラップして型情報をつけたものを返すような仕組みになっています。

まず、`createPath`を利用してパラメータの文字列を取り出し、ルート名をキーにしたオブジェクトのような型を構築します。
その型が`createRoutider`の型引数になるようにし、その型が使われるようにした`useRoute`などの関数を`createRoutider`の返り値で返します。

こうすることで、パラメータの文字列を利用した型を`useRoute`の引数や返り値で利用することができます。

簡略化したコードで表すと下のような形です。

```ts
type Component = any // 実際はVueからimport

// 実際はパラメータの抽出を行いますがここではパスそのものとして書きます
type Path<T extends string = string> = string & { path: T }
// Tが型引数になっているので型推論によってTが渡した文字列そのものの型になってくれます cf. createPath('a')ならPath<'a'>が返り値の型
const createPath = <T extends string>(path: T): Path<T> => (path as unknown) as Path<T>

type Route<P extends Path = Path> = {
  path: P,
  component: Component
}
type Routes = Record<string, Route>

// ここでもRを型引数にすることで関数内でRが利用できるようになってます
// 引数を「routes: R」ではなく「routes: Routes」にしてtypeof routesを利用すると、
// routesに渡すオブジェクトそのものの型ではなく、Routesという広い型になってしまいます
const createRoutider = <R extends Routes>(routes: R) => {
  // ここでRを利用した型を構築する
  const useRoute = <N extends keyof R>(name: N): R[N] => routes[name]
  return { useRoute } // Pathsなどの構築したものを利用して型をつけた関数を含むオブジェクトを返す
}

const { useRoute } = createRoutider({
    Index: {
        path: createPath('/index'),
        component: {}
    }
})

const route = useRoute('Index')
// ここでrouteはしっかり上で宣言した{ path: Path<'/index'>, component: {} }という型になっています
```
[TypeScript Playgroundで開く](https://www.typescriptlang.org/play?#code/C4TwDgpgBAwg9gWzHAdhFwoF4oEMUhQD0RUg+dqDZaYPYMAagK4SDSDIJEMAlknAE7ABQPokKAAVcwABYAeACpQIAD2DoAJgGcoK4J1YoA5tnWbtOgHz6NW3VABkUAN5QwosQC4oMgL48Axqg1QvnBCiECLi+tKyCspq5kbGABSO4q5SAJSuoZJSplimiU54anQoANYocADuKKmFwk7SxnwC0ABKcHSKEkKRiiiqdWE4mTl2PFAOThkANGP+iMhoGK7wHIu8ns1QbR0QajgtED6cShKxulNb7YqN3r6YAUGK28CsShCc4S090Zc7KgmcK67VwtGq5UbjEhQQDKDNDAOYMLUASQyASiVABSugHUGQD6DIBo9URgGnLQCT3oBNBkA0QyzHwoPx0FQQZ7QHASABy3z6ahKEBAcAAZlsEihcAgIK5GektgBtRkAXWwpkBfzF-MFktmgWAdE4KDsUGptKBUHcxFImRUgCsGQCWDIA7BkJWMAQQwWlEYwBmDLjACYMgEUGTGAItTAA6miMA1CqAAIZAFUMgDWGQAdDIByhkA9QyACYZEYAV+KJPE8t0pmHsOrp+v0D2Cz1e73itlm4wAkn15K5i+MazWki5-IFgpl4gByIjaN5yVupGa12s+VboYBVzy1zzuVJ8Cl+OWKfSZoFt8tdns8OcQAB0PCAA)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/388834/fe560092-106f-acfc-e149-e0ae1ac8035a.png)
上のように補完がききます。

## `createPath`

```ts
const path = createPath`/items/${'itemId'}`
```
という不思議な書き方をしますが、この書き方をすることで文字列中の文字を抽出しています[^te]。
タグ付きテンプレートといって、この`createPath`では第一引数に`${}`ではない部分の配列(型は`TemplateStringsArray`)、第二引数以降に`${}`の中身の部分が受け取れます。
例えば、``createPath`a${'b'}c${'d'}` ``だと第一引数が`['a', 'c']`、第二引数以降が`'b', 'd'`になります。
これを利用し、第二引数以降の型を推論させることで`['itemId']`という型を取り出すことができます。

## `createQueries`
実際は関数を利用しなくても利用側できっちり`as const`記述することで正しい型をつけられます。

```ts
const route1 = {
  query: ['a', 'b'] // literal type wideningでstring[]になってしまい、'a'と'b'という情報が欠落してしまう
}

const route2 = {
  query: ['a', 'b'] as const // as constをつけることでliteral type wideningを回避して['a', 'b']になる
}

const route3 = {
  query: createQueries('a', 'b') // 型推論を利用してas constと同じ挙動にする
}
```
Vue Routiderでは別の形の型を利用しているので`as const`では動きません。

# 制約事項

ドキュメントの[Limitations](https://vue-routider.sapphi.red/limitations)の通り、いくつかの制約事項があります。

- `<router-link>`は型がつきません
    - Vueのコンポーネントが受け取る`props`をジェネリクスを扱う方法がおそらくないので対応できません
- `/:id?`や`/:id*`のような存在しないことのあるパラメータは今のところ対応していません
    - これは今後対応していく予定です
- 動的にルートを追加する`addRoute`や削除する`removeRoute`は対応していません
    - optionalな型が存在するようにするのは手ですが、今のところは対応予定はないです[^optional]

# あとがき

Vue2用の[Composition APIのプラグイン](https://github.com/vuejs/composition-api)でちょこっと型推論周りを触って面白いなと思っていたので、ちょうどいいタイミングでちょうどよさそうな題材があったので、とてもよかったのと作っていて楽しかったです。:grinning:
設計的にVue Routerに密結合なのでどうにかしたいと思っていますが難しそうです。
また、11月にリリースされる予定のTypeScript 4.1.0ではtemplate string types[^4]という型をテンプレート文字列みたいに扱える機能も入る予定みたいなので、リリースされたら`createPath`の宣言方法を変えるのもよさそうかなと思っています。

普段は、traPというサークルでいろいろやっているのでよろしければそちらでの記事も見ていただけると嬉しいです:pray:
https://trap.jp/author/sappi_red/

[^1]: 試しにVue Routiderを導入して書き換えたものでは1kB程度増加しました。減った0.4kBはtree shakeの影響だと思われます。
[^4]: [microsoft/TypeScript#40336](https://github.com/microsoft/TypeScript/pull/40336)
[^optional]: `assert T is type`という手もありますが、実装してもあまり嬉しくなさそう
[^te]: この形ではなく``createPath`/items/${param('id')}` ``という形でもよいのですが、こっちのが短かったのでこっちにしました。こっちの場合VSCodeだと補完がちょっと怪しい挙動をしてる気がしますが、打つ文字数自体少ないので問題ないかなと思っています。
[^guard]: https://vue-routider.sapphi.red/navigation-guards, https://vue-routider.sapphi.red/route-type-guards
[^be]: `beforeEnter`以外にもいくつか必要なものはありますが、これは型推論の仕組み上必要です。必要なものはドキュメント(https://vue-routider.sapphi.red/ )に記述しています。
[^i39]: [sapphi-red/vue-routider#39](https://github.com/sapphi-red/vue-routider/issues/39)