概要
本記事では、Nuxt.js
のプロジェクトに TypeScript を導入するにあたって、バックエンドの API を型定義することで API 呼び出しにも型付けの恩恵を受けられる方法を示します。
本記事の通りに API を型定義すると、API のレスポンスだけでなく、Path も TypeScript で定義できます。これにより、レスポンスを型安全に扱えますし、API の Path が変更された時にコンパイルエラーで変更箇所を検知できるといったメリットを享受できます。
API の型定義はライブラリの導入によって実現できます。Nuxt.js を例に説明しますが、他のフロントエンドにも導入しやすいはずなので、ぜひご活用ください。
本記事の流れ
はじめに既存実装の問題点を提示し、そのあとライブラリの導入によって API を型定義した結果を示します。問題点が解消できることを確認し、具体的な導入手順を補足します。随所に弊社で僕が実際に感じている課題、実際に運用して解決できそうな課題についても補足することで、実運用へのヒントを示します。
ソリューションはライブラリの導入なので、導入方法等はライブラリの README が適切であることから、僕の記事では問題点の提起と実運用に関して論述するところに厚みを持たせます。How To が気になる方は途中で示すライブラリの GitHub へ飛んでもらえればと思います。
既存実装の問題点
一例として、下記のように@nuxtjs/axios
を用いて API を叩いている事例を示します(※)。
const userResponse = await this.$axios.$get("/api/v1/contents/" + params.id);
このコードが抱えている課題として以下があります。
- API の Path をベタ打ちしているので、typo しても気づかない
- API の仕様が変わったとき、Grep して当該 API を使っている場所を探さないといけない
- 返り値の型がわからない。せっかく TypeScript を使っているのに any になってしまう(特に TypeScript で書いている場合)
Path のベタ打ちや、返り値が any になるのは axios
のみならず fetch や ky を使っていたとしても当然とも思われますが、これらの課題を API の型定義によって解消できる、というのが本記事の主眼です。
※)独自の axios
ラッパーを定義してエラーハンドリング等を共通化する事例もありますが、本記事では axios
をそのまま使っている例を出しました
弊社の状況
ここで、せっかくなので、自社で導入した背景を補足として説明します。
Nuxt.js に TypeScript を導入したのは Nuxt.js で構成したサイトをリリースしておよそ半年経過してからでした。
導入の際、主な懸念(TS を導入してもさほど効果が上がらないリスク)は
- Vuex の型付け
- Template 内の型付け
- REST API の型付け
でした。
Vuex についてはほとんど使用しないようにしていて、ユーザーのステート管理くらいでしたので、vuex-module-decorators
を導入して少し実装を調整すると終わりました。
Template 内の型付けも、(導入当時はまだ機能不足の感がありましたが)VSCode の Vetur プラグインで補完や null 判定等もかなり DX を保って行えるため、そこの懸念も小さかったです。
ただ問題は REST API との通信で、GET した値が any になるなら as で型定義することになるでしょうし、POST/PUT 時に投げる値については都度 Body の内容を型定義しても、他のページで同じ API に POST するときに Body に型定義することを強制できないです。
(ちょっと乱暴な例ですが)下記のように僕が実装したとして、
await this.$axios.$put(`/api/users/${id}`, {
title: "hogehoge",
body: "hogehoge",
} as UserAccount);
他のメンバーが同じ API を型付けせずに実装しても、コンパイルエラーしないため、運用でカバー、レビューでカバーみたいに風化することが予想できますね。
await this.$axios.$put(
`/api/users/${id}`,
{
tilte: "hogehoge",
boby: "hogehoge",
} // 型付けしなくても別に通るし、なんなら上のtitle, bodyがtypoしているけどコンパイルエラーしないですね
);
TypeScript API Type Definition
といった単語でググっても全然ヒットしないので(※)、一般的なライブラリのような形に落とし込まれたソリューションは無いのかなと思っていた、というところまでが弊社の状況でした。
※)ある程度のエキスパートであれば、もしかすると各プロジェクトに応じて何かしらの解決策、例えば API の Path と渡す Body の型定義を辞書のようにした巨大なオブジェクトを使って Repository を構成するようなことをやってのけているかもしれませんが、そこまで頑張るのなら TypeScript やらなくていいかな・・・と思っていました。
API を型定義するとどうなるか
それではここまでの前提を踏まえて、API を型定義することで問題点を解消していけることを説明していきます。
ライブラリの概要
API を型定義するには aspida(アスピーダ)というライブラリを使います。国産のライブラリ、というか、普通に都内のフロントエンドの勉強会に登壇されているような方が 2019 年末に立ち上げたプロジェクトであるため、気軽に作者に連絡や改善要望できる点は嬉しいポイントです。
ここまでの話で、気になって仕方なくなった&ある程度 README 読んだらわかりそうだって方は以下のリポジトリを見てください。
API を型定義したあとの世界
それでは aspida
を導入することで実装内容がどのように変わり、どのような利点を享受できるのかを説明していきます。
実装内容が変わること
まずは実装で変わることを説明します。
以下の既存実装のソースコードがあるとします。先程例示したように、API の Path をベタ打ちで、リクエストの Body は無理やり型定義しています。
await this.$axios.$put(`/api/users/${this.$route.params.id}`, {
title: "hogehoge",
body: "hogehoge",
} as UserAccount);
aspida
を導入し、API の型定義をした後は下記のように書けます。
await this.$api.users._id(this.$route.params.id).$put({
data: {
title: "hogehoge",
body: "hogehoge",
},
});
見た目の観点で変わっていることとしては
- API の Path を文字列ベタ書きではなく、オブジェクトのプロパティをネストして表現している
-
$axios
ではなく$api
という別のクライアントを使っている
といったところです。
続いて、このように実装内容が変わることで、どのような利点があるのかを実装時、運用面の両方から整理します。
実装時に得をすること
もし API の Path を typo すると、コンパイルエラーになる
aspida
で API を定義すると、API の Path がオブジェクトのプロパティとして表現されます。
したがって、例えば/api/users
の users を user を打ち間違えるとコンパイルエラーになります。
PUT する Body の内容を typo してもコンパイルエラーになる
同様に、渡すリクエストボディも予め型定義された状態で扱えます。
例えば title を tilte に打ち間違えるとコンパイルエラーになります。
運用時に得をすること
API を誰が使ってもリクエストボディ、レスポンスボディの内容を型定義できる
解説は省略しましたが、レスポンスボディも型定義した状態で扱えます。
そのため、aspida
の記法で API を扱っている全エンジニアが、型の恩恵を受けた状態で開発ができます。
API の仕様変更時に追従していない実装をコンパイルエラーで気づくことができる
API を仕様変更したとき、例えば
- POST を PUT に変えた
- user を users に変えた
- リクエストボディの内容が変わった
- レスポンスの内容が変わった
といった場合、aspida
上で型定義した内容を書き換えると、対応した実装がすべてコンパイルエラーになります。
このあと、実際の導入手順を示した上で、コンパイルエラーで検知できる例を示しますので、合わせてご覧ください。
導入方法
それでは実際の導入手順を示していきます。詳しくはリポジトリの README を見てください。
大まかには下記の手順で実施します。
-
aspida
の npm install - apis ディレクトリを作成し、なにか一つ API を型定義して
aspida --build
-
$api
オブジェクトを$axios
を元に Wrap し、Vue インスタンスと Nuxt.js コンテキストに Inject する
npm install
aspida
は 2020 年 3 月現在、axios、ky、fetch を Wrap して型定義機能のついた HTTP クライアントに変換することができます。
ここでは axios
を Wrap する前提で説明します。また、すでに@nuxtjs/axios
を利用していることも前提です。していない場合は別途、素の axios
をインストールしてください。
npm install @aspida/axios
apis ディレクトリを作成し、なにか一つ API を型定義してaspida --build
ここが aspida
の最大の特色です。
apis ディレクトリをルートディレクトリに作成したら、そこから API の Path の構造と同じ構造でディレクトリを作成します。
ちょうど、Nuxt.js でページのルーティングをディレクトリ構造で表現するのと同じです。
/api/v1/users/${userId}
という API を作成したい場合は、apis/v1/users/_userId@number.ts
にその API の型定義を記載します(※)。実際の型定義の方法は README をご覧ください。
しかし、ここで型定義を書いたとしても、最終形ではthis.$api.hogehoge.hogehoge....
という形で利用できることから、$api
オブジェクトを生成する必要があります。
それをやってくれるのがaspida --build
コマンドです。
/apis
ディレクトリの中のディレクトリ構造から、自動で API の型定義ファイルを生成してくれます。
{
"scripts": {
"api:build": "aspida --build"
}
}
※)$axios
のほうで、BASE_URL を設定していれば、$axios
を Wrap するクライアントを作るのが aspida
なので/api
ディレクトリを作成する必要はありません。
$api
オブジェクトを$axios
を元に Wrap し、Vue インスタンスと Nuxt.js コンテキストに Inject する
aspida --build
コマンドが行うことの実態は、/apis/$api.ts
に自分が定義した API をすべて吐き出すことです。実際に出力された内容を読むと理解しやすいと思います。
続いて、これらを Nuxt.js Component で利用する必要があるので、inject します。
import { Plugin } from '@nuxt/types'
import `axios` Client from '@aspida/axios'
import api, { ApiInstance } from '@/apis/$api'
declare module 'vue/types/vue' {
interface Vue {
$api: ApiInstance
}
}
declare module '@nuxt/types/app' {
interface NuxtAppOptions {
$api: ApiInstance
}
}
declare module 'vuex/types/index' {
interface Store<S> {
$api: ApiInstance
}
}
const plugin: Plugin = ({ $axios }, inject) => {
inject('api', api(axiosClient($axios)))
}
export default plugin
最後のapi(axiosClient($axios))
がポイントで、@nuxtjs/axios
を aspida
が用意している Wrap メソッドのaxiosClient()
を実行することで Wrap オブジェクトを生成し、その結果を inject しています。
弊社の場合だと、$axios
自体に BASEURL の設定などをすでに終わらせていたので、設定内容がそのまま aspida にも適用できたのが便利ポイントです。
こうすることで、API の Path がディレクトリに基づいてオブジェクトのプロパティのネストで表現できるようになり、例えば返り値のAxiosResponse
も型定義ファイルで定義した型を扱えるように Wrap されます。
コンポーネントで使ってみる
コンポーネントで使うときはthis.$api
またはcontext.$api
で利用できます。エディタで実際に書いてみると、以降の API の内容がプロパティになっているので補完されていくことが確認できるでしょう。
以降は、バックエンドエンジニアから API の仕様を聞いたら aspida
の書き方に落とし込んで型定義ファイルを自動生成し、実装していくのを繰り返します。
注意点
一度勘違いされたことがあるのですが、型定義自体をミスってしまうことは防げません(そもそも TypeScript がそうですからね)。
なので、例えばサーバーサイドから返ってくる API のtitle
を間違ってnumber
と定義してしまうと、実行時エラーが起きる原因になります。
利点の検証
さて、実際に aspida
を導入する内容を説明したところで、上記で示した API の型定義の利点を検証してみましょう。
ここでは、API の仕様変更時に追従していない実装をコンパイルエラーで気づくことができる
を検証してみます。
API の仕様変更でコンパイルエラーになる実際の事例
実際に弊社のソースコードで、POST /user/register
というユーザー登録の API があるのですが、これをPOST /users
という RESTful な API に書き換えてみます。
やるべきことは以下の 2 つです。
-
/apis/user/register/index.ts
の内容を/apis/users/index.ts
にコピー -
aspida --build
を実行
その瞬間、npm run dev
を実行しているコンソールが動き出し、下記のようなエラーが出ます。
ERROR ERROR in /Users/.../signup.vue(87,53):
87:53 Property 'register' does not exist on type '{}'.
85 | methods: {
86 | async submit (params: RegisterBody) {
> 87 | const registerResponse = await this.$api.user.register
| ^
88 | .$post({
89 | data: {
90 | email: params.email,
このように、API の仕様を変えたとき、既存実装が Type Error を吐いてくれるので、移行すべき実装に気がつくことができます。
API を容赦なく仕様変更したり削除できるかどうかは長期的に見て大きなメリットがあるはずです。特に弊社はほとんど Web 開発を僕 1 人でやっているようなベンチャーですが、PMF するまでの改善プロセスでプロダクトを作っては捨てていくことが多く、aspida
のように容赦なく捨てられるような工夫を仕込んでおくことにメリットを感じています。
導入にあたっての注意点など
ライブラリの成熟度
aspida
自体は始まったばかりのライブラリなので、破壊的な仕様変更が発生する可能性があります(2020 年 3 月現在、まだメジャーバージョンが 0)。弊社のように小回りがきくベンチャーなら導入しやすいですが、タイトな現場ではまだ様子見したほうがいいかもしれません。
逆に、作者の方は Twitter 等でも活動していたり discord でコミュニティを作っているので、改善要望等の連絡はつきやすいということが利点としてあげられます。僕は破壊的変更があったときにアップデートタイプする際、めっちゃ DM で質問攻めにさせていただきましたw
https://twitter.com/m_mitsuhide
まとめ
API の型定義自体は応用範囲が非常に広く、backend が strapi や microCMS、Firebase の REST API で作られています、という場合にも応用できると考えられます。
本記事を読んだ方が、REST API をより便利に TypeScript から利用する契機を掴んでいただけたら幸いです。
告知
僕の会社ではオンライン家庭教師に特化したマッチングサービス NoSchool(https://noschool.asia/ )を運営しています。
親御さんは生徒にどんな教育を受けさせていいかわからない、家庭教師側は個人で活動していても集客がままならない、といった双方の課題をどうやって解決していくかを考えつつ、Zoom 等の普及に伴ってオンライン家庭教師市場そのものを大きくしていこうというベンチャーです。
Nuxt×TypeScript + Laravel + AWS + Firebase な環境で開発しています。事業領域、開発内容に興味ある方はぜひお気軽に遊びに来てください!
https://twitter.com/Meijin_garden