とろろこんぶろぐ

かけだしR&Dフロントエンジニアの小言

react-testing-library の `act` と `waitFor` を使うべきタイミング

はじめに

react-testing-library を使うと React アプリケーションのコンポーネントの挙動をテストすることができます。

詳しくは公式ドキュメントを読んでください。

https://github.com/testing-library/dom-testing-library testing-library.com

ただ、react-testing-library は若干厄介なところがあり、 state の更新や非同期な処理が含まれる挙動をテストしたい場合に、 React と react-testing-library の挙動を理解していないとテストが思ったように動作しない、という経験をした人もいることと思います。

こういう場合に、 act で処理を囲ったり、 waitFor で処理を囲うことで「なぜか分からないけどテストが動いた」という経験がある人もいるのではないでしょうか。

僕もそうです。

react-testing-library の動きを少しでも理解を深めるために、よく対処方法として上がる actwaitFor について調べたのでまとめておきます。

react-testing-library

react-testing-library 自体には対したコード量はありません。

src ディレクトリ配下: react-testing-library/src at main · testing-library/react-testing-library · GitHub

react-testing-library 自体は、 react を render して HTML DOM を用意したり、イベント発火時に act を囲うようにする程度です。 実際のコンポーネントテストのイベントモック関数自体は testing-library の共通関数 (user-events など) を使うことになると思います。

act の概要

React のコンポーネントをテストしようとした際に、該当の処理コードを act で囲めよという警告が出ることがあります。

    Warning: An update to XXX inside a test was not wrapped in act(...).

    When testing, code that causes React state updates should be wrapped into act(...):

    act(() => {
      /* fire events that update state */
    });
    /* assert on the output */

React の act については、警告の中にもある通り、このドキュメントに辿り着きます。

ただこれだけ読んでも分かるようで分かりません。 react-testing-library で act を呼び出しますが、実体は React 本体にあります。

  • react-testing-library の act の実装はここになります。
  • これは、 React (react-dom) のテスト用のユーティリティ(test-utils) の act を呼んでいます。
  • react-domtest-utils の act は React.unstable_act を呼んでいます。
  • 結局 act の本体は、 ReactAct というコードで、 React 本体が用意してくれているロジックになります。

元々出ていた警告も、 react-testing-library などの testing-library 系のコードが出力しているわけではなく、 React 本体のコードが出力しています。

act については、この記事や記事内でも参照されている Example を読むと概要が理解できます。

簡単にまとめると以下だと理解しました。

  • React の状態(ステート)の更新があった際に仮想 DOM への反映が同期的に行われても、実 DOM へのレンダリングコミットは非同期的に行われている。
  • 非同期で行われる全ての実 DOM のレンダリングコミットの完了を待たずに expect してしまうと、意図しない実 DOM の UI 状態でテストをしてしまう。
  • act メソッドで該当のレンダリング処理が走るコールバックを囲うことで、コールバック内での実 DOM 反映のレンダリングコミット処理が全て完了するのを待つ。
  • これにより、 テストするための実 DOM の UI 状態が意図したものになり、正しく expect を実施することができる。

act の概要図

act の内部実装

さらに実際の act の内部の動きは、この記事に詳しく解説されています。

act 関数ではおおよそ以下の動きをしているようです。

  • ReactCurrentActQueue というキューを用意しておく。
  • 与えられたコールバックを実行してレンダリング処理をした際に発生した状態更新による Fiber の UI 更新処理を ReactCurrentActQueue にキューイングする。
  • (ここから正確に読み解けなかったけど) スケジューリングされた UI の更新時に、キューイングしておいたものを flushActQueue 関数で全て実行する。
  • ↑で flush し終えたら act を resolve する。

こういう動きが内部的に行われているので、 act によって状態の整合性が正しく保たれることが(なんとなくですが)理解できました。

ちなみに、冒頭の warning が表示されている箇所では ReactCurrentActQueue.currentnull である(つまりキューが初期化されていない)ことを確認しているようです。

Fiber の UI 更新処理が実施されるタイミングで、キューが空ならおかしいってことなんでしょうか(わからん)。 React-testing-library のテストの時はわかったんですが、普通に npm run dev の時も内部的に act されているってことですか?詳しい人教えてください。

user-event は act を呼んでいる

react-testing-library のコンポーネントテストを実現する際には、testing-libraryuser-event を使ってユーザーの操作イベントをモックすることがあります。

軽く調査すると user-eventact で囲われているので自前で囲う必要はない、といった内容の記述が見られます。

これを具体的にコードで追うと、

user-eventdispatchEvent で呼び出される wrapEvent@testing-library/dom の設定値である eventWrapper を呼び出します。

その eventWrapper という設定値を react-testing-library で定義しています。

ここで指定されたコールバックを act で囲うようになっています。

これによって userEvent.click を何も気にせずに使ったとしても、 act で囲われた状態で利用することができています。

ステートが非同期で更新されるケースには act では対応できない

userEvent.click が act で囲われているにも関わらず、 act で囲えという警告が出ることがあります。

これは大抵の場合、ステートが非同期に更新されてしまっていることによるものだと考えられます。

例えば、

  • ユーザーの操作では API 呼び出しだけが行われる
  • 非同期的に返却された API のレスポンスによってステートが変更される
  • レスポンスデータに準じた UI に画面が更新される

ステートが非同期に更新されるケースの例

上記のように、レンダリングコミットではなくステートの更新自体が非同期的に行われてしまうと、 act では対応しきれないようです。

これを対処するためには、 act で sleep 処理を挟むという手が取れそうです。

await act(async () => {
  await sleep(1100); // wait *just* a little longer than the timeout in the component
});

react-act-examples/sync.md at master · threepointone/react-act-examples · GitHub

これによって act で挟んでいる sleep 中に API のレスポンスが返ってきて UI が更新されれば、 expect が正しく動くことにはなりそうです。

waitFor を使う

しかし act で sleep を囲うくらいなら、 waitFor を使うという手が取れそうです。

waitFor のコードは以下です。

dom-testing-library/src/wait-for.js at main · testing-library/dom-testing-library · GitHub

デフォルトの設定では、 1秒間の間 50 ms ごとに expect を繰り返すことで DOM 反映を待ってくれます。

これを挟み込んでおけば非同期的なステート更新にもおおよそ耐えうることになりそうですね。

ただ、本来的には時間に左右されるような処理にはさせたくないですが、ある程度仕方のないことかもしれません。

findBy 系のメソッドは waitFor も実行している

ちなみに findByRole のような findBy 系のメソッドは waitFor を呼んでいます。

Async Methods | Testing Library

これによって非同期的な UI 更新がなされる場合、 getByRole では動かなかったテストコードが findByRole では動くということが起きます。

おわりに

react-testing-library の actwaitFor 、またそれに関わるメソッドなどについて調査しました。

  • act: React が提供する実 DOM へのレンダリングコミットを待ってくれる仕組み
  • waitFor: 1秒間の間にたくさん expect しながら待ってくれる仕組み

また分からなくなったときに気が向いたら追調査してみようと思います。

Langchain の要約API load_summarize_chain (Map-Reduce) 詳解

はじめに

GPT 系で要約を実施するために、 Langchain の API (load_summarize_chainmap_reduce オプション ) を利用する機会がありました。そのために周辺の記事などを少し眺めてみる機会があったのですが、適切な解説記事がなかったため今回執筆してみることにしました。

筆者は LLM や生成 AI 、ましてや機械学習ディープラーニング、そもそも Python にも詳しいわけではないため、一部事象を誤解していたり、間違った解説をしている可能性があります。 ご容赦いただくとともに、間違っている点についてはご指摘いただけると幸いです。

前提

LLM (Large Language Model) は文章を要約することにも利用可能です。 しかし、LLM には基本的に一度に処理できるトークン数の最大値 (Context Window) が決められています。

Context Window のお話 - Speaker Deck

OpenAI の GPT の Context Window は公式ドキュメントで確認できます。

モデル gpt-4 で 8,192 tokens です。

1 トークンでどの程度の文字列なのかは、確かめることができる Tokenizer があるので、試してみるとわかりやすいです。

https://platform.openai.com/tokenizer

Tokenizer の例

LLM には Context Window があるため、Context Window を超えてしまう長い文章をインプットとして扱いたい際には工夫が必要になります。

その一つが Langchainload_summarize_chain の Map-Reduce オプションです。

詳しくは Summarization | 🦜️🔗 Langchain か、以下に示す参考記事を読んでいただく方がより理解が進むと思います。

今回の記事では、Map-Reduce の処理がどう実施されており、入力の引数に渡すべき

  • map_prompt
  • combine_prompt
  • collapse_prompt

にそれぞれどういうプロンプトを渡すことが求められているのか?の参考になれば幸いです。

参考にした記事

要約処理の解説

長い文章を要約するためには、大きく二つの工程が必要になります。

  • 文章の分割処理 text_splitter
  • 分割された文章の要約処理 load_summarize_chain

text_splitter は指定されたチャンクサイズで文章を分割してくれる API です。

TokenTextSplitter | 🦜️🔗 Langchain

今回は text_splitter については詳しくは触れません。

要約 API load_summarize_chain

長い文章を分割したドキュメント (i.e. documents) を要約するために、 load_summarized_chain API を利用します。

load_summarized_chain には、

  • Stuff
  • Map-Reduce
  • Refine

の3種類のオプション (chain_type) があります。

Stuff オプション

このうち、 Stuff オプションは当初の課題である 「要約したい文章か Context Window を超えてしまっている場合にも要約したい」という課題はクリアしていません。 引数として複数の documents を渡す作りにはなっていますが、呼び出す側があらかじめ Context window を超えない形で複数の documents に収えておく必要があります。

内部の挙動としては、引数で受け取った複数の documents を改行コードで結合し、大きい一つの文章にして LLM に投げています

ちなみに後述しますが、 Map-Reduce オプションの中で Stuff Chain が使われています

Map-Reduce オプション

Map-Reduce オプションは、それぞれの分割したドキュメントに対して LLM にて要約処理を実施 (Map 処理) し、それらの結果を結合して 1つの要約を再度 LLM にて処理を行う (Reduce) 方式になっています。

Refine オプション

Refine オプションは時間の関係で調べられていません。ごめんなさい。

load_summarize_chain の Map-Reduce オプション

Map-Reduce オプションでは、 map 処理と reduce 処理に分かれて処理が実施されます。

上記を図示すると、以下のようになります。

ベーシックな挙動

この時、load_summarize_chain を呼び出す際に指定した引数の token_max を超えない場合には collapse 処理は行われません。つまり collapse_prompt は利用されません。

続いて、一度 map 処理で生成した documents のリストが token_max を超える場合です。 この場合上記のベーシックな挙動に加えて、 collapse 処理が行われます。

collapse 処理が必要な挙動

collapse 処理では、map した結果の documents から、token_max を超えないギリギリの document のリストを作ります

新たに括った 「token_max を超えないギリギリの document のリスト」で collapse_prompt で combine 処理と同様の LLM の要約処理を実行します

ちなみに、

  • チャンクサイズが大きく map した結果が token_max を超えていた場合で、 collapse 処理でも map したリストの数と変わらずまだなおその文章が token_max を超えてしまっていると、無限ループになる恐れがあります。
  • collapse_prompt は指定しないと combine_prompt が使われますが、これは collapse 処理が combine 処理と似た処理を実施ているからになります。

以上が、 load_summarized_chain の Map-Reduce オプションの挙動を調査した内容になります。

まとめ

今回の記事では、load_summarized_chain の Map-Reduce オプションの挙動を調査しました。

  • map_prompt
  • combine_prompt
  • collapse_prompt

のそれぞれでどういうプロンプトを用意すると良いか参考になると幸いです。

もし何か間違っている点があれば、ご指摘ください。

Next js v14 で考える開発チームの事業的貢献

はじめに

今年の 2023年5月に Next.js の v13.4 がリリースされ App Router が Stable になり Vercel 推奨の実装方式となりました。 さらに10月 Next Conf にて、 Next.js v14 がリリースされ App Router を代表する新機能である Server Actions が Stable になりました。

App Router はこれからのWeb開発の未来を担うフレームワークになっていくことが予想されており注目度の高い技術です。一方、これまでの Pages Router からの変更点の多さ、機能の興味深さ、設計の複雑さ、動作の不安定さなども含め、さまざまな要因でいまでもたくさん議論になっています。

今後どこかのタイミングで App Router へ対応する必要があることは明らかなものの、技術の不安定さが気になる上に、ある程度規模の大きい組織になると事業側に「なぜ案件開発の工数を投げ打ってでも App Router にすべきなのか?」を説明する責任が出てくる方もいることと思います。

この記事では、 Next js v14 で開発チームがこれからどのように事業的な貢献をしていくことができるか について自分なりに考察したいと思います。

Next.js v14 の新機能

Next.js そのものや v14 でのアップデート内容、 App Router について、また Server Components や Server Actions については、この記事では詳しく触れません。プログレッシブエンハンスメントと Intercepting Routes (と Parallel Routes) についてだけ後述する本題の内容のために少し触れます。

プログレッシブエンハンスメント

Next.js で期待されている一つの特徴がプログレッシブエンハンスメントです。 これは Next.js 特有の機能ではなく、RemixSvelte などの他のフレームワークでも意識されている機能です。これはつまり特殊な機能の一つが追加されているわけではなく、これからの Web アプリのスタンダードな機能の一つになっていくと言えます。

例えばフォームの送信機能でいえば、

もっと身近な例で言えば next/link を使いリンクタグを配置していると、

  • JavaScript が動作する環境では、画面描画が CSR でリンク遷移が行われる
  • JavaScript が動作しない環境でも、ただの a タグとして動作しハードナビゲーションにはなるがリンク遷移が行われる

ことになります。もし JavaScript が動作しない環境ではリンク遷移が行えないとしたら、サービスとして致命的ですよね。これもプログレッシブエンハンスメントの一種と言えると思います。

ここでいう 「JavaScript が動作しない環境」は、ブラウザの設定などで JavaScript を有効にしていないユーザーのことだけではなく、 必要な JavaScript ファイルがダウンロードされて利用できるようになるまでの間のような JavaScript がまだ有効になっていないユーザー のことも含めています。むしろプログレッシブエンハンスメントが語られる背景はこちらのユーザー体験を向上させることが目的にされていることの方が多いと思います*1

最近一部界隈ではアクセシビリティが注目されることが増えてきましたが、僕個人的には プログレッシブエンハンスメントもアクセシビリティの一種 として捉えて良いものだと思っています。

特に大規模サービスでは、ユーザーの絶対数が多い分、ギガが足りなかったり低速なネットワーク品質を使ったりしているユーザーたちをも救うことはとても重要な役割があり、これはサービスにとってとても大事なアプローチになると思っています。

Intercepting Routes

Next v14 の特徴といえる機能の一つに Intercepting Routes という機能があります。 一覧画面から詳細画面への遷移というよくあるユースケースで、詳細画面への直接遷移とは異なるUXを提供するものです。*2 これはもはや実際の動きを見た方が早いので、公式から辿れるサンプル に参照し、一覧からの詳細画面への遷移と直接遷移での詳細画面表示を比較してもらうと分かりやすいと思います。サンプルコードは、こちらです。

またよりリッチで実際にサービスとして活用されている例でいえば、直接 Intercepting Routes のような UX が提供されている Web 版の Instagram もイメージしやすいと思います。 PC版のブラウザでアクセスしていただき、一覧画面から詳細画面に遷移したり、リロードしたりしてもらえるとどういう機能かわかると思います。 Instagram の場合は、モーダル上で詳細画面→別の詳細画面への遷移も可能なので、より UX のリッチさが伝わることと思います。

Instagram の Intercepting Routes の例

このように同じ詳細画面を示す URL に対して、

  • 一覧画面から詳細画面への遷移時はモーダルによる表示
  • 詳細画面への直接遷移 (他のサイトからの流入や URL 入力による遷移 etc.) は、ページ全体での表示

という二種類の異なる UX を提供することが簡易的に実装できる技術になっています。

事業的貢献への考察

プログレッシブな仕様検討への進化

これらの新しい要素が提供するものはユーザーへの更なる Web 体験の向上に他なりませんが、その一方でこれらの技術の登場によって要件定義もプログレッシブな形で定義する必要が出てきました。

これまでの仕様は「アプリが使える状態になったとき」をトリガーにした、0か1かの仕様になっていることがほとんどだと思います。

しかし、プログレッシブエンハンスメントや Intercepting Routes の登場により、JavaScript が利用できない期間や、次の画面に遷移するときまで、ユーザーへの体験価値をプログレッシブに向上させることができるようになってきています。

つまり、 仕様自体も0か1かではなく、状況に応じた仕様の定義が必要になる と考えています。

これまでは仕様が 0 か 1 かしかなかった

これからは段階的に UX 要件を定義する

企画ディレクターにとっても 「これまでリーチできなかったユーザー体験の向上ができること」 は魅力に感じてもらえると思います。

もちろん Next.js v14 App Router でなくても工数をかければこれらの機能を実装することはできます。ただ App Router を導入してこれらの技術装着を容易にしておくことで、将来進化し続ける当たり前品質に追いついていくための礎になります。企画ディレクターに Web とプロダクトの進化について理解してもらうことで、導入に少しでも理解を示してもらえるのではないかと考えています。

エンジニアの事業的貢献

この新しい時代の流れに対して僕が思う一番重要なことは、 企画ディレクター側から提案される開発への要求とは直接的に紐づかない ということです。

例えば、JavaScript が動作するまでの期間にフォームのサブミットができないことや一覧画面から詳細画面への遷移時に画面遷移の UX が悪いという課題は、よほど Web 開発に積極的な企画ディレクターではない限り、解決すべき要求として提示されることは滅多にないと思います。*3

つまり 開発側からこれらを新しい機能仕様を提案する 必要があります。

企画ディレクターがユーザーに対してやりたいと思っている 要求 から実際の仕様に落とし込む 要件 までの間にエンジニアが入り込むことで、ユーザー体験として一番いい形を要件定義できるようになるはずだと思っています。

エンジニアが企画ディレクターの仕事を奪うのではなく、お互いに得意な領域でサポートし合いながら、エンジニアが要件検討から実装までよりシームレスに関わっていくことが必要になっていくと思っています。

要件検討に開発も入り込む

これが上手くいけば、

  • 企画ディレクター側としては、自分たちでは考えが及ばない範囲に対して、これまで以上のユーザー体験の向上にリーチすることができる
  • 開発側としては、自分たちの目指したい技術選定を要件検討とセットで提案することでチームとして納得感を持って導入できる

となるのが理想です。*4

あともう一点補足するとしたら、我々エンジニアに求められるのは、より複雑になっていく仕様に対して 「そこまで大きな追加工数がかからない」という技術力 がこれまでに増して必要になりますね^^

企画・開発ともに、 「ユーザーに対して素晴らしい体験を享受してもらうこと」 を共通の目標として掲げた上で、それぞれが貢献できることをお互いに実施することで、自分たちの採りうる最大限の要件を作り出すことが今後より一層必要になると思っています。

おわりに

この記事では Next.js の App Router の登場に対してどういう事業的貢献ができるか考察しました。

  • プログレッシブエンハンスメント
  • Intercepting Routes

これらの技術に焦点をあてて、これからは仕様そのものもプログレッシブになっていくことと、事業側である企画ディレクターの要件検討に開発が入り込む必要性について書きました。 なかなか開発から要件を取り込むことは難しい障壁がたくさんありますが少しずつ実施しつつ、 将来的には自分のプロダクトに App Router を導入していければと思っています。

*1:ちなみにこのような JavaScript が利用できない期間を TTI (Time To Interactive) と呼んだりします。さまざまな JS / Web フレームワーク(例えば React )で、 フレームワークのための JavaScript のダウンロードやハイドレーションに時間がかかってしまうことで TTI が問題視されることがあり、プログレッシブエンハンスメントのような機能がフィーチャーされている認識です。

*2:実際にはParallel routesとの組み合わせによる実現になりますが、簡単のため説明を省いています。

*3:もちろん少し遠い未来にこれらの機能が当たり前になっていくことで、企画ディレクター側から「なになにのサイトと同じように実装してください」という要求が来ることはあるかもしれません。しかし、現時点でプログレッシブエンハンスメントと Intercepting Routes はそこまでメジャーな機能とは言えない状況です。

*4:理想の形にするのが簡単ではないことは身をもって体感中です

GraphQL 成熟度モデル

記事の概要

この記事は、Meta 社 relay.dev チームの Jordan Eldredge 氏の Tweet で紹介された GraphQL 成熟度モデル (GraphQL maturity model) を個人的な見解を加えながら和訳した記事です。

jordaneldredge.com

GraphQL を実装する上で、どの程度 GraphQL を使いこなせているか判断するための参考になれば幸いです。

実際の成熟度モデルの和訳

最初の Tweet

私は、GraphQLの利点がまだ十分には理解されていないと思っています。ほとんどの組織では、GraphQL の提供する価値を捉えきれていません。

そこで、私は「GraphQL成熟度モデル」をスケッチしてみました。あなたの組織はどの程度成熟して(=使いこなせて)いますか? もし以下に示す13の成熟度を達成していたとしたら、よりGraphQLを評価するのでしょうか?

No. 1

1/サーバーがGraphQLをサポートしていることです。アプリケーションのモバイルとウェブの両クライアントは、サーバーのコードを変更することなく、また他のクライアントへの影響を心配したりすることなく、普通にクエリの作成、データの追加・削除ができます。

No. 2

2/ 型やフィールドはきちんとドキュメント化されていることです。GraphiQLは、インターナルな環境でインタラクティブなプレイグラウンドやドキュメントとして機能します。開発者は必要なデータを見つけることができ、何度も同じサーバー上の処理を記載する必要はありません。

No. 3

3/ GraphQL のサーバーとクライアントの両方で、それぞれの言語の型システムを統合して利用していることです。これによりネットワーク API の境界を超えた型安全性が確保されます。

No. 4

4/ スキーマをデフォルトで null 許容にするという GraphQL Working Groups 推奨のベストプラクティスを採用していることです。一般的にサーバー上で発生するフィールドのエラーは大したことがなくハンドリングできる UI のエラーです。これによりアプリの回復力(可用性?)が高まります。

No. 5

5/ Node の仕様を採用していることです。Graph のほとんどのオブジェクトが強力なIDを持ちます。クライアントは、個々のオブジェクトのデータを簡単に再取得できます。

No. 6

6/ GraphQL クライアントフレームワークが、Node 仕様の強い ID を使用して正規化された形式(キー ⇒ オブジェクト)で GraphQL のデータを保存していることです。表示上におけるデータの不整合は発生せず、クライアントのメモリ使用量も少なくて済みます。

No. 7

7/ GraphQL サーバー側でリストをモデリングするために Connections 仕様を実装していることです(ほとんどの UI は見せかけのリストです)。 GraphQL クライアントフレームワークは、どのような表示においても機能する堅牢なページネーションを提供することができます。

No. 8

8/ 各 UI のコンポーネントが、GraphQLフラグメントを使用してコンポーネントに閉じた状態でデータを定義していることです。クエリーは、コンポーネントに書かれたフラグメントから構成されます。コンポーネントは、他のコンポーネントに対する破壊的な影響を心配することなく、コンポーネント内部に閉じてデータを追加/削除することができます。

No. 9

9/ GraphQLのクライアントフレームワークが、コンポーネントのフラグメントを使って表示単位ごとに単一のクエリにまとめていることです。UIは、幾重にも重なったローディングの状態になることなく、1回でロードされ表示されます。これにより、ユーザーはあなたに感謝することでしょう。

No. 10

10/ サーバーとクライアントの両方が @-defer@-stream をサポートしていることです。UXデザイナーは、UXを向上させる場合にのみ、1行追加するだけでコンポーネント内でネストされたローディング状態を宣言的に許容することができます。

No. 11

11/ GraphQL クライアントフレームワークが、各コンポーネントのフラグメントを活用して、各コンポーネントで指定されたサブスクリプションを構築していることです。バックエンド側のストアに変更を加わると、直接影響を受けるコンポーネントのみが再レンダリングされます。これにより UI がよりレスポンシブになります。

No. 12

12/ クライアント側のアプリを開発するエンジニアが、エディターで GraphQL のランゲージサーバーを活用していることです。各フィールドは利用可能な内容でオートコンプリートされます。フィールドの上にカーソルを合わせると、ドキュメントや型を見ることができます。非推奨のフィールドは、IDEで波線や取り消し線などのが表示されます。

No. 13

13/ クライアント側のアプリを開発するエンジニアの GraphQL エディターは、フィールド/型のクリック時の定義ジャンプに対応しています。他のクライアントの関数にジャンプできるのと同様に、フィールドのサーバー実装に簡単にナビゲートすることができます。

最後のツイート

...これはまだ、今日時点で実現可能なことをなぞったにすぎず、完全なものとは思いません。

どんな技術にもコストと利点があります。しかし、その利点がより広く実現されれば、GraphQLのコストはそれほど痛くない(高くない)ものと感じられると思っています。

個人的見解や解説、補足など

筆者は JS x Relay x VSCode での開発経験が大きいので、以下はそれに偏った補足になります。

No. 1 について

これは GraphQL の実装をきちんとしているよね、という基礎的な話なので、特に補足はありません。

No. 2 について

GraphiQL は実際に開発を進める上でとても有用です。基本的には Apollo Server や Yoga Server などの GraphQL サーバーで plugin や option を設定するだけで使えるようなものなので、使える状態になっているべきです。さらに GraphQL スキーマのフィールドやクエリに対してコメントをしていれば GraphiQL でドキュメントとしても利用可能です。GraphQL スキーマにも Lint をかけておき、コメントは必須な状態にしておくと良いです。

cf. Introducing GraphQL-ESLint! – The Guild

No. 3 について

GraphQL のスキーマから GraphQL Codegen や Relay などのツールやフレームワークで型生成は行えるので、使うべきです。No. 3 まではメリットというよりは GraphQL 使う上で必須なものと言えると思います。

No. 4 について

個人的には、フィールド全てをデフォルトで null 許容にすることはなかなかないのかなとは思います。ただ UI 上では仮にエラーが起きて表示できなかったとしても、画面そのものの表示を取りやめるほどではない場合もあります。そういったケースに備えてフィールドの null 許容は、選択肢として頭に入れておくと良いと思います。

No. 5 について

Node の実装は正規化キャッシュのためにも、オブジェクトの使い道に応じて定義しておくとよさそうです。 ただ現実問題、全てのオブジェクトに一意の ID を設定するのに限界があるので、全て Node 化は難しいとは思います。

また Relay では使って当たり前の雰囲気を出しつつ、Node に関するドキュメントがそこまで充実してないので、 Node を理解するのがそもそも難しいというのも取り込まれづらい要因になっていそうです。

No. 6 について

Relay では、 Node を実装していれば特に意識することなく正規化キャッシュしてくれます。

No. 7 について

Connections も取っ掛かりづらい仕様ですが、カーソルベースのページネーションであることを理解してしまえばこれまでのオフセットベースのページネーションを包含した概念であることが理解できると思うので、一度実装してみると良いと思います。

No. 8 について

フラグメントコロケーションは、 GraphQL の代名詞とも言える機能ですね。もっと手前に出てきてもいいような。

No. 9 について

Relay では、手間がかからずフラグメント化したクエリを Persisted Query にまでしてくれるので、悩むことは少ないと思います。

No. 10 について

これは、 Tweet を読むまで deferstream が既に使える状態であることを知りませんでした...!勉強不足でした...。 ...というわけで、まだ使ったことがないので見解は特にナシ。

No. 11 について

Subscription は WebSocket や GraphQL-SSE などを用いてリアルタイムに UI に変更を加える機能です。実装コストは安いわけではありませんが、有用なケースはあると思うので、 UX の向上のために取り入れるのも良いと思います。

No. 12 について

VSCode であれば GraphQL の拡張機能と .graphqlrc の config の設定があれば動作します。 ちなみに deprecated directive は VSCode だと取り消し線ではなく波線になったと思います。

No. 13 について

こちらも VSCode であれば GraphQL の拡張機能と .graphqlrc の config の設定があれば動作します。

おわりに

以上が GraphQL 成熟度モデルの和訳と少しの解説になります。

GraphQL は難しいと捉えられることも多く、メリットよりもコストが注目されることも多々あるイメージがあります。

すべてのメリットを享受しようとせず、難しさとその代わりに得られるメリットを体系的に理解して、順々に使い慣れていくことができればと思います。

この記事が少しでもその助けになれば嬉しいです。

msw が Service Worker に依存する時代が終わっていた話

概要

msw はいつの間にかモックツールとしてデファクトスタンダードになりました。

github.com

Mock Service Worker という名の通り、Service Worker を利用して、アプリが API サーバーとやりとりするリクエスト/レスポンスをモックすることができるツールです。

ただ、ふと気がついたら Service Worker なしでモックできるようになっていたので、その小ネタを書きます。

この記事は Recruit Engineers Advent Calendar 2022 の9日目の記事です。

adventar.org

Node でも使える msw

ご存じの方も多いと思いますが、 msw は Node.js でも利用できます。

Node - Getting Started - Mock Service Worker Docs

Node.js で利用できるのはわかりましたが、 Service Worker は Node.js では動作しません

では、どうやって Node.js で msw を動かしているかというと、 これは Node.js の httpintercept してモック処理を実現しています。

msw 内で使われているモジュールはこれ↓

github.com

Node 向けの msw 起動 API setupServer を呼び出すと、内部で SetupServerApi クラスを作ります。

https://github.com/mswjs/msw/blob/main/src/node/setupServer.ts#L15

そこで上記で紹介したモジュールの interceptors のうち、ClientRequestInterceptor が登録されます。

https://github.com/mswjs/msw/blob/main/src/node/SetupServerApi.ts#L46-L49

これで Node.js の http から発行されるリクエストを横取りして加工することができるようになります。

具体的な処理はおおよそ以下のようになっています。

this.interceptor.on('request', async (request) => {
      // モック用のリクエストを生成
      const mockedRequest = new MockedRequest(request.url, {...})

      // msw で登録した handler でレスポンスを生成
      const response = await handleRequest(...)

      // 生成したレスポンスのモックデータを返す
      if (response) {
        ...
        request.respondWith(response)
      }

      return
})

msw の listern を開始したら上記 'request' イベントのコールバックハンドラを登録します。

Node.js の内部で request.end が発火されたタイミングで emitter を発火させて上記が処理されます。

  // 'request' イベントを発火することで intercepter のコールバックが動く
  this.emitter.emit('request', interactiveRequest, requestId)

https://github.com/mswjs/interceptors/blob/main/src/interceptors/ClientRequest/NodeClientRequest.ts#L154

Node.js は http.get を呼び出すと必ず自動的に req.end を呼び出すと Node.js のドキュメントに書かれているので、

HTTP | Node.js v19.3.0 Documentation

request.end を hook しておくことで Node.js の request 処理をモックできるようにしているようです。

これで、Nose.js で Service Worker を使わずとも msw を利用できるようになっています。

ブラウザでの msw

msw はブラウザ側で利用する場合 Service Worker が使われます。

しかし実は Service Worker が利用できないケースでもちゃんと動作してくれます。 Service Worker が利用できないケースというと具体的には、 https 化されていないサーバーで Web ページが提供される場合などです。

Service Worker は https でないと動作させることができません*1

例えば、 Storybook で msw を利用していて、その Storybook をビルドして S3 にアップロードし、社内だけで閲覧できるように共有するとします。 特に何も意識せず S3 を公開すると http://hogehoge-storybook.s3-website-ap-northeast-1.amazonaws.com/ といった http はじまりの URL で共有されます。

これを実際に表示すると、https じゃないので msw は Service Worker が使えず利用できないかと思いきや、なんときちんと動作します。

ブラウザ向けの msw 起動 API setupWorker を呼び出すと、内部で以下のような処理が走ります。

      // service worker が利用できないフラグを立てておき
      useFallbackMode:
        !('serviceWorker' in navigator) || location.protocol === 'file:',

...
    // フラグが立っていたらフォールバックモードで起動する
    this.startHandler = context.useFallbackMode
      ? createFallbackStart(context)
      : createStartHandler(context)

https://github.com/mswjs/msw/blob/main/src/setupWorker/setupWorker.ts#L175-L177

そこで上記で紹介した interceptors のうち、今度は FetchInterceptor が登録されます。

https://github.com/mswjs/msw/blob/main/src/setupWorker/start/createFallbackRequestListener.ts#L25

そうすると Node のときとほぼ同じようなコードがあります。

  interceptor.on('request', async (request) => {
    // モック用のリクエストを生成
    const mockedRequest = new MockedRequest(request.url, { ... })

    // msw で登録した handler でレスポンスを生成
    const response = await handleRequest<SerializedResponse>( ... )

    // 生成したレスポンスのモックデータを返す
    if (response) {
      request.respondWith(response)
    }
  })

FetchInterceptor は名前の通り fetch を intercept できます。

https://github.com/mswjs/interceptors/blob/main/src/interceptors/fetch/index.ts

  protected setup() {
    // 元々の fetch を退避させておき
    const pureFetch = globalThis.fetch

    ...
    // globalThis の fetch を独自に上書きする
    globalThis.fetch = async (input, init) => {

      ...
      // その中で 'request' イベントを発火して↑のハンドラを実行する
      this.emitter.emit('request', interactiveRequest, requestId)

      ...
      // モックが実行されなければ pureFetch を叩く
      return pureFetch(request).then((response) => { ...

https://github.com/mswjs/interceptors/blob/main/src/interceptors/fetch/index.ts#L23-L42

ブラウザ側で実行される globalThis.fetch を加工してしまうことで、 Service Worker を使わずとも msw のモックが実現できます。

つまり、もはや msw は Mock Service Worker と言っておきながら Node, ブラウザともに Service Worker に依存せず動作することができるツールとなっていました。

Node v18 Fetch 対応

上記で一応記事の内容としては終わりですが、一応補足的に Node v18 で入った fetch について記載しておきます。

Node v18 で experimental ですが fetch が利用できるようになりました。

Node.js 18 is now available! | Node.js

先ほど msw が Node.js 上で intercept していたのは、 http でした。 これに加えて fetch も intercept できるのかというと、現状ではまだ対応しきれていないようです。

記事執筆時点で、 msw の Node v18 対応の Issue はオープン状態でした。

github.com

この辺りも似た Issue ですね。

Node 17 and 18 silently don't get intercepted · Issue #246 · mswjs/interceptors · GitHub feat: support Node.js 18 by milesrichardson · Pull Request #283 · mswjs/interceptors · GitHub Support Undici · Issue #159 · mswjs/interceptors · GitHub

まとめ

msw が Service Worker に依存せず動作することができることを紹介しました。

  • Node.js → http のリクエスト/レスポンスを intercept
  • ブラウザ → デフォルトでは Service Worker だが動作しなくても fetch を intercept

msw が多様なユースケースで利用されることで、気付かぬ間にいろいろ進化しているんだなと知ることができました。

*1:http でもlocalhost のみ例外的に許容されています

保守性の担保のために仕様を整理する方法

はじめに

リリースされたプロダクトをエンハンス開発していく上で、保守性を保ち続けることがとても大事なのは言うまでもありません。 特に複雑な要件は実現するために難解なロジックを書く必要があります。 このことから 要件の複雑さと保守性はトレードオフになりがち です。

保守性を保つ方法はさまざまです。 例えば、可読性を高めるような書き方をした上でコメントを残したり、 Lintやテストなどツールを使ったりなどです。

しかし、この記事では書き方やツールではなく、 そもそも複雑になりそうな仕様を整理し要件から取り外すこと で保守性を保つ取り組みを紹介します。

この記事は Recruit Engineers Advent Calendar 2022 の2日目の記事です。

adventar.org

開発者が仕様の整理に入り込む

フロントエンドエンジニアをやっていると、カジュアルに難解なUI要件が定義された仕様と向き合うことが、 たまによく あります。

プロジェクトやチームによって仕様を決める方法やフェーズ、担当者はそれぞれだと思います。 リクルートでは主に企画職のディレクターが担当して仕様を決めています。 開発職(エンジニア)がディレクターから要件をインプットしてもらい実際に開発を行います。 ディレクターは業務的なドメイン知識は詳しいものの開発の詳しい知識はありません。

仕様は、エンジニアにインプットされる段階では、ビジネス上のさまざまな検討を終えて決定されたものです。 そのためこれまでは基本的には仕様の内容は翻されません。

しかし、実際に仕様の内容をエンジニアが確認すると、開発する上で実現したい要件に対して不要な仕様が混ざっていたり、オーバーキルな仕様が含まれていることがあります。

仕様の是非をきちんと分析して整理し、実装すべきかどうかを見極めることにより、不要なコードが含まれずに品質を担保し続けることができます。

現在、新たな取り組みとして、仕様に対する正式なインプットのタイミングとは別に、要件定義期間で開発レビューをフローに取り入れて、仕様の最適化を図る取り組みを行なっています。

開発者レビューによって取り外すことに至った仕様を3つ紹介します。 *1

例1 直感的仕様

最初の例は、UIに寄った「使い心地」に関する要件です。

取り外した仕様について

内容
背景 対象箇所は検索結果一覧への絞り込み機能のモーダル。絞り込みの条件選択は、カテゴリ(検索軸)ごとに複数の条件を指定して検索できる。条件の数が多いので検索軸ごとにアコーディオンで開閉可能になっている。
課題(目的) なるべく複数の検索軸で条件の絞り込みを行なってもらうために、シームレスに条件を追加させたい。
打ち手(仕様) アコーディオンの中身を下限までスワイプしたら自動的に次のアコーディオンが開く。上限で前のアコーディオンの展開も同様に行う。

なぜ取り外したか

目的に対し適切な打ち手とは言えなかったからです。

考察と取り外しアプローチ

UX 的な仕様は良い悪いが主観的になってしまい判断が難しいです。 特にフロントエンド要件では見た目や使い心地に関わる仕様が多いため、仕様が直感的に陥りがちなケースがあります。 簡単に言うと 「なんとなくこういう動きの方がよさそうだから入れたい」 というやつです。

こういった仕様に対しては2つのアプローチを採ります。

1. なぜ必要なのかをきちんと言語化してもらう

ディレクターに対してなぜこの仕様が必要なのかを言語化してもらいます。 今回の場合は 「複数の検索軸で条件の絞り込みを行なってもらいたいから」 でした。

言語化して気づけることは、「複数の検索軸で条件の絞り込みを行なってもらいたい」に対する打ち手は、 スワイプ案の他にもある ということです。 例えば、よく組み合わせられる条件をユーザーに提示してあげたり、条件ごとに検索結果の件数を表示するという手もあるかもしれません。

本来はそれらを横並びにして検討すべきですが、その考察はディレクター側ではまだ できていない 状況でした。 このことをきちんと説明し理解してもらうことで、本当に「スワイプでアコーディオンが開く」が適切な打ち手なのか再検討してもらうきっかけになります。

2. 実際にプロトタイプを触ってもらう

もう一つは、実際に触ってもらい動作を確認してもらうことです。 想像している使い心地が本当に得られるか、 プロトタイプ的に簡単に作って触ってもらいます

イメージと同じかそうではないかを判断してもらうことができますし、 開発側は実際に作り込んだ場合の難しさを推測しやすくなります。

フロントエンドは HTML, CSS, JS をシュッと書けば CodeSandbox とか CodePen とかで試しに動きだけ作るようなことがやりやすいという特徴があります。 場合によっては Chrome ブラウザの DevTools から本番の画面に対して直接 CSS を書き換えて、 こういうことですか? と尋ねることもできますね。

--

今回の例でも、 1. のようなすり合わせを行った上で、2. で実際に開発したものを触ってもらいました。 その結果、ディレクターは 「思っていたより使い心地が良くない」 とわかり、他の方法と比較検討した上で、今回は仕様装着を見送りました。

今回のように実装装着前に仕様を外すことができれば、不要なコードを含めることなく保守性の担保につながりますし、プロダクトとしても最適な形に近づけることができます。

例2 網羅的仕様

取り外した仕様

次の例は例外的な処理仕様についてです。

内容
背景 複数のユーザーが同時に同じイベントデータを編集/削除することがありうるシステム。
課題(目的) ユーザAが一覧画面を表示している時に、同じタイミングでユーザBが編集画面でデータを削除したら、ユーザAは一覧画面から詳細なデータを閲覧するためにリンクをクリックすると 404 ページになってしまう。
打ち手(仕様) ユーザAが一覧画面を表示している間にリンク先の要素が削除されていたら、ユーザAがリンクをクリックして遷移しようとしたタイミングでデータが削除されている旨をスナックバーで通知する。

なぜ取り外したか

実際にどの程度発生しうる問題か調査したところ、イベントを削除することが多くても数ヶ月に一度発生するかどうかであり、実際には ほとんどありえないケース であることが発覚したためです。

考察と取り外しアプローチ

仕様だけ聞くと確かに、ユーザAの知らぬところでデータの変更があったら知りたい、というのは納得できそうです。 しかし、本当に必要なのかどうかはこの仕様の内容だけでは分かりません。

こういったケースは、例外処理系の仕様にありがちです。 これに対してはなぜ必要なのかを言語化してもらうと同時に、 開発工数が費用対効果に見合うのか を検討してもらうアプローチを採ります。

簡単に言うと、 「どのくらいそういう状況が起きるのか?」 ということです。

仕様を検討するディレクター側としては、あらゆるユースケースに全て対応させる仕様を網羅的に検討しがちです。 例えば、登録や変更のときに通知を出すなら削除の場合も出さなければ... といったことです。

今回のケースでは、上述した通り ほとんどありえないケース だったことが発覚し、仕様は取り外しとなりました。 この機能を作る開発工数を使って、別の機能を作った方がよっぽど効率的と分かりました。

例3 ウォーターフォール的仕様

取り外した仕様

最後の例はリデザインです。

内容
背景 カレンダー形式でイベント情報を表示するUI。次の月に進んだり前の月に戻ったりできる。これはリデザインのため既存で使われている機能のため、利用されるユースケースが多分にあることがわかっている。
課題(目的) カレンダーの月移動を、既存にあるようなボタンによる遷移ではなく、より便利に遷移させたい。
打ち手(仕様) 横スワイプでカレンダーの月を変更する。

なぜ取り外したか

リデザインの 初回リリースには載せない という判断を選択しました。

考察と取り外しアプローチ

リデザインという文脈上、ディレクターやデザイナーの意思としてより良いUI/UXを取り込みたいという意図は尊重できます。

今回のようなケースでは、なぜ必要かを言語化してもらうと同時に、 リデザインした初回のリリースで含めるべき MUST の要件なのか? を検討してもらいます。

実は、開発としては、リデザイン=画面全ての要素を作り直すことになります。 さらにこのリデザインのタイミングで、既存の描画ロジックを再度書き直しを行なっていたり、コンポーネントの再整理などまで行っていたりします。

つまり たとえ同じ機能のままでもコードとしては大規模な修正が入っている ということです。

これは悪いことではありません。

このリデザインのタイミングでコードの品質水準を高く上げるとともに、コンポーネントやロジックの整理とテストの拡充が行われることで、初回リリース後の エンハンス開発のスピードを大幅に上げる ことが想定されています。

要するに、まず初回リリースではMVP版をきれいな形で素早くリリースできれば、一度にドカンと混ぜなくてもその後必要な機能を必要に応じて高速にエンハンスで開発していけば良い、ということです。

先の要件に話を戻すと、カレンダーUIをスワイプで切り替えるためには、切り替えたタイミングで前後の月の情報を再取得して画面を再描画する必要があります。

次の月になるのは、スワイプをどのくらい行った時点で行うのでしょうか?ローディングはどう出すか?WebではスワイプでブラウザバックになるがUXとして問題はないか?日毎の予定を表示する画面ではスワイプで遷移できないため統一性はなくてもよいか? など、検証と実装で1日、2日で終わるようなものとは思えません。

この議論と検討を重ねた上で、リデザインの初期リリースと同時に実施するのは得策ではないと判断して、一時的に仕様を取り下げてもらう決断 をしてもらいました。

ディレクターには、今後のエンハンス計画を依頼しています。 後回しとするために取り外した仕様はこれだけではないため、それぞれの WANT 機能に対して優先度と検証工数、それに対する費用対効果を洗い出し、整理することをお願いしています。

おわりに

プロダクトの保守性を保つアプローチとして、 "開発者が仕様の整理に入り込むこと" を紹介しました。 具体例として、3つの注意すべき仕様の具体例を紹介しました。

全ての話に共通するのは、 仕様の「なぜ」を深掘りし言語化すること です。 追加される仕様が「なぜ」必要なのかをきちんと言語化してもらうことで、ディレクターにとっても要件の意図を再整理でき、エンジニアにとっても納得感を持って開発に臨むことができます。

そして忘れてはいけないもう一つの大事なこととして、 仕様の正解はユーザーが持っている ということです。

取り外した仕様が「本当に取り外してよかったのか?」はユーザーのみぞ知ることを忘れてはいけません。 取り外した仕様は、今すぐにでも必要な機能だったのかもしれません。そうしたらエンハンスで次の月にでも素早くリリースする必要があります。 このために、Google Analytics のログや、ユーザーヒアリング、ファネル分析などを緻密に行って、ユーザーが求める機能を追い求め続けることが大切です。

こういった取り組みを続けていくことで、良いプロダクトに成長していけることを信じて日々取り組んでいます。

*1:どのプロダクトの事象かの情報は伏せており、仕様や要件、各種やりとりが実際とは多少異なっていることをご了承ください。

Zod のスキーマが使えるAPIクライアントZodios を紹介したい

概要

TypeScriptでフロントエンド開発をしているとバックエンドのAPIを呼び出す際に、APIのパラメータ、レスポンスの型付けをしたくなります。

僕は最近この型付けにZodを使い、APIクライアントにはZodiosというライブラリを使っています。

github.com

この記事では、Zodios でZodのスキーマ定義から型安全なAPIクライアントを作る方法を紹介します。

他のやり方

Types定義

純粋にパラメータとレスポンスの type を Type Alias で定義し、fetch やaxios などの素のAPIクライアントに型付けするやり方です。 APIのパスと、パラメータ、レスポンスの紐付けを人間が管理することになるため、ミスを防ぐことができません。

aspida

この課題のためにaspidaがよく使われている印象があります。

github.com

Open APIからaspidaのAPIクライアントのコードが書かれているファイルを自動生成できるので、バックエンド側でOpen APIがすでに用意されているプロジェクトではとても有用だと思います。

自動生成されたファイルから適切なクライアントをimport することで、パス、クエリ、レスポンスといった型補完を効かせながらクライアントを記述できます。

またOpen APIがなくても、あらかじめ決められた型の形式でAPIの型定義を書くことで、その型定義を元にAPIクライアントが記載されたファイルを自動生成してくれます。

課題に対して効果的な解決策になります。

ただ、(好みの問題ではありますが)書き味や使い方に少し癖があります。 APIクライアントのメソッド名は自動的に作られたものを利用することになります。 ファイルを自動生成させるため、npm scriptにaspidaのビルドスクリプトを追加して、watch しておく必要があります。

gRPC, tRPC

バックエンド側の開発に制約がなければgRPCや最近流行りのtRPCを利用することで、バックエンド側で記載されるAPI定義をそのままフロントエンドを型として利用する方法もあります。

ちなみにZodiosでもzodios-expressを利用すれば、tRPC的に開発することもできます。

GraphQL

同様にバックエンド側で開発可能であればGraphQLを使う方法もあります。Schemaを定義することで、バックエンドとフロントエンドで共通認識を持つことができるだけでなく、フロントエンドではAPIクライアントまで自動生成可能です。 Relayであれば公式にrelay-compilerが用意されているため、公式が型補完までサポートしてくれています。

ZodとZodios

Zod

zod.dev

Zodは主にバリデーションのために使われるスキーマ定義ライブラリです。

バリデーション文脈で言えば、yupもよく使われています。 またNext.jsは最近v12.3ajvを公式にサポートしています。

Zodは、近年バリデーションだけで使われることを飛び越えて、スキーマ定義を活用したライブラリが数多く開発されてきており、エコシステムか発展しつつあります。

Zodios

github.com

ZodiosはZodのスキーマ定義を活用したAPIクライアントです。 あまりメジャーではありませんが、zodのスキーマ定義をベースにAPIクライアントを動的に生成するものとしては十分なライブラリです。

ただ名前からもわかる通りaxios のインターフェースをベースにしているのですか、最近 axios を使いたいモチベーションが低いので、名前とaxiosが依存に入るのが微妙だなと思っています。

実際のAPI呼び出しのライブラリにはaxiosを利用せず、プラグイン的にfetchを使うことができます。 ただしfetchを利用する場合でもaxiosのI/Fをベースに記述されているためaxiosの依存は必要です。

Zodiosを使う

まずはZodでスキーマ定義をします。 API のレスポンスが以下のような形式だったとします。

type User = {
  id: number;
  name: string;
  email: string;
};

Zodのスキーマ定義は以下のようにします。

const userResponse = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string(),
});

以下のように TypeScript の型を手に入れることができます。

export type User = z.infer<typeof userResponse>;

Zodのよいところはスキーマ定義を行うことで、単純なTypeScriptの型では表現できない制約を正しく表現できることが利点です。 そのため、以下のように書くことで得られる Type は同じですが、ドキュメンテーション的な役割を担うことができます。 

const userResponse = z.object({
  id: z.number().int(),
  name: z.string().min(1).max(1024),
  email: z.string().email();
});

スキーマ定義を元にZodiosのAPI定義を作成します。

export const userApi = zodios
  .apiBuilder({
    method: "get",
    path: "/user/:id",
    alias: "getUser",
    description: "Get a user",
    response: userResponse,
  })
  .addEndpoint({
    method: "post",
    path: "/user/:id",
    alias: "postUser",
    parameters: [
      {
        name: "body",
        description: "User Info",
        type: "Body",
        schema: z.object({
          name: z.string().min(1).max(1024),
          email: z.string().email(),
        }),
      },
    ],
    description: "Create a user",
    response: userResponse,
  })
  .build();

apiBuilderaddEndpointでエンドポイントを定義の生成、追加しています。 エンドポイントごとにaliasで自分の好きなメソッド名を定義できます。 リクエストするクエリやボディに対しても、zodのスキーマ定義を利用できるだけでなく、descriptionを記載しておくことができます。 API定義をimportしてZodiosのクラスを作成します。

const apiClient = new Zodios(API_ENDPOINT, [...userApi]);

実際に利用する際にはalias で書いたメソッド名を利用し、パスを記述する必要はなく、クエリ、レスポンスは型補完が効きます。

const data = await apiClient.getUser({ params: { id: 1 } });

レスポンスの型補完が効いている

さらにZodのスキーマ定義を使ってリクエストやレスポンスに対してバリデーションが効きます。 開発段階では、スキーマ定義通りじゃないリクエストやレスポンスにZodiosのエラーで気づくことができるので有用です。

Zodのバリデーションエラーをthrowできる

Zodios から Open APIをアウトプットすることで、ドキュメントとしても活用できる...と思いましたが、 現時点で適切なライブラリが存在しないようです(残念)。

github.com (空のレポジトリ)

Zod から Open API 自体は可能なので、工夫すればできるかもしれません(やってない)。

www.npmjs.com

この記事のコードサンプルが書かれているレポジトリはこちらです。

github.com

余談: MSW

フロントエンド開発では言わずと知れたモックツールです。 mswのハンドラにはレスポンスを直接型付けできます。 ただこれでは、パスとレスポンスの型は人間が正しく付けてあげる必要があるので、ミスを防げません。 そこでzodios の型を流用してパスとレスポンスの型付けを行うことができます。

export function restGet<Path extends Paths<Api, "get">>(
  path: Path,
  resolver: ResponseResolver<
    RestRequest,
    RestContext,
    Awaited<Response<Api, "get", Path>>
  >
) {
  return rest.get(`${API_ENDPOINT}${path}`, resolver);
}

これにより、パスが補完され、パスに応じたレスポンスの型に補完が効くようになります。

  restGet("/user/:id", (req, res, ctx) => {
    const id = req.params.id;
    const data = users.find((u) => u.id === Number(id));

    if (!data) {
      return res(ctx.status(404));
    }

    return res(ctx.status(200), ctx.json(data));
  }),

takepepeさんの記事を参考にしました。

zenn.dev

またzodiosの型がとてもよく作られているので、そこまで複雑な型パズルをすることなく、型補完が適用できました。

まとめ

ZodとZodiosを利用したAPIクライアントへの型付けについて書きました。 MSWに型をつける方法もついでに紹介しました。

Zodiosは特にファイルを自動生成することなく、割と書き心地よくAPIクライアントを型補完した状態で書き進められるライブラリです。

あまりメジャーではないのですが、僕はだいぶ使い心地が気に入ったので、多くのひとに使ってもらえたらいいなと思います。