ゼスト Tech Blog

ゼストは「護りたい。その想いを護る。」をミッションに、在宅医療・介護業界向けのSaaSを開発しています。

tRPCとZodによるTypeScriptで型安全なアプリケーション開発

こんにちは。株式会社ゼストでアプリケーションエンジニアをしている海老原です。

5月9日にZEST MEET(以下MEET)というプロダクトがリリースされました。今回はこのMEETの開発で行った技術的な取り組みについてお話ししようと思います。

開発ストーリーについては別途記事がありますので、そちらも是非ご覧ください。

techblog.zest.jp

 

MEETの技術的な概要

MEETはいわゆるWebアプリケーションで、ブラウザを動作環境とするフロントエンドアプリケーションとサーバーサイドアプリケーションがHTTP経由で連携することで動作します。

弊社にはZESTというプロダクトが元々あり、これは訪問看護・介護・診療のためのスケジュール作成を支援するサービスです。MEETはZESTで作られたスケジュール情報を外部のケアマネジャー様向けに公開するというプロダクトのため、既存プロダクト側のサーバーとMEET側のサーバーで分けて開発しました。そのため以下のようにサーバーサイドが2つ、フロントエンドが1つという構成になっています。

プログラミング言語

開発に使用するプログラミング言語はTypeScriptに統一しました。

各個人の専門性は活かしつつ、フロントエンドとバックエンドを流動的に行き来できる開発チームを目指しており、今回はその一環として、開発言語の統一を行うことで、領域を跨ぐ際のハードルが1段階下がることをねらいとしています。

過去の経験上、フロントエンドエンジニアとバックエンドエンジニアがきっちり別れた開発チームで起こりがちな課題があります。1つの機能を作る際、遅れが発生したり、そもそも最初の見積もり段階からバックエンドの開発ボリュームとフロントエンドの開発ボリュームが大きく異なっていたりする場合に、支援に入りたくても技術的な壁からなかなか思うようにいかないことがあります。その結果、待ちが発生しないように別の機能開発に着手することになりますが、皆忙しく働いているにも関わらず、機能が顧客になかなかデリバリーされないという状況になります。いわゆるリソース効率は高いが、フロー効率は低いというものです。もちろん、フロントエンドとバックエンドで必要とされる知識や技術は異なるのですが、プログラミング言語を同じにすることによって、まずは読める、という体験は背中を後押しする上で重要だと考えています。

tRPCの導入

近頃、T3 Stackという言葉を聞くようになりました。

create.t3.gg

tRPCはこのT3 Stackで紹介されているライブラリの1つになります。

https://trpc.io/

アプリケーション開発の生産性を語る上では、快適な開発体験と型の安全性は切っても切れないものと考えています。今後のプロダクト開発での採用を視野に入れて、技術調査も兼ねてMEETの開発でtRPCを使ってみることにしました。

tRPCはGraphQLでいうスキーマファイルなどの中間表現を必要とせず、サーバーサイドで定義したエンドポイントの型定義をクライアントサイドでimportし、その情報を元にAPIクライアントを定義します。この仕組みにすることで、APIコールにおける型安全性を守ることができます。

(※GraphQLを批判する意図はありません。GraphQLはクライアントサイドでクエリーの内容を組めることが大きな特徴であり、tRPCが目指すような型安全性と共存できると思います。今回は分かりやすさのために挙げさせていただきました。)

tRPCの型定義をクライアントに共有する手段には少し苦労しました。tRPCの特徴として、APIクライアントを生成する元となる情報が同じプログラミング言語の実装ファイルとなるので、クライアント実装の生成コマンドが必要なく、その変更がリアルタイムに反映される点があります。しかし、サーバーサイドとクライアントサイドでパッケージを分けると、変更の反映がパッケージのバージョン単位となってしまうため、その特徴が失われます。

そのため、MEETの開発ではサーバーサイドとクライアントサイドのパッケージを同居させたモノレポで開発しています。それぞれの依存関係はyarn workspaceで管理し、また型の解決のためにTypeScript 3.0から導入されたProject Referencesを使用しました。

Project Referencesに関してはtRPCを単純に使うだけであれば必要ありません。しかしながら、MEETの開発のようにサーバーが2つ以上ある場合には、エンドポイントのtRPC型定義の解決がうまくできなくなるため、必要になります。

MEETの開発を通して3ヶ月程tRPCを使ってきましたが、感想としては悪くないです(謎の上から目線すみません)。特に、前述のフロントエンドとサーバーサイドを行き来するという文脈では、サーバーサイドでのエンドポイントがリアルタイムにフロントエンドに反映され、変更による型エラーが出るというのは開発体験としてとても良いものでした。

Zodの導入

サーバーサイドにTypeScriptを使用する懸念点として、TypeScriptの型システムがありました。

TypeScriptは構造的部分型という仕組みを採用しており、代入可能性は型の継承関係ではなく型の構造によって決まります。そのため、全く関係がないはずの型同士が代入可能になったりします。JavaScriptとの互換性を考えるとそうなるのはしょうがないような気もしますが、モデルの実装をする際には懸念が残ります。例えばUserId型の変数にTeamId型の値は代入できないようにしたいのですが、TypeScriptでは難しかったりします。

type UserId = string;

type TeamId = string;

const teamId: TeamId = "teamId";

// 型エラーにならない。
const userId: UserId = teamId;

MEETではこの懸念を解決するために、モデルの実装にZodというライブラリを使用しています。

https://zod.dev/

TypeScript-first schema validation with static type inference

とある通り、Zod自体はバリデーションを行うためのライブラリなのですが、これを使うと構造的部分型の弱みをある程度カバーすることができます。

前述の、UserIdとTeamIdを区別する例は.brand()という機能を使って解決することができます。

https://zod.dev/?id=brand

const userIdSchema = z.string().brand("UserId");

const teamIdSchema = z.string().brand("TeamId");

type UserId = z.infer<typeof userIdSchema>;

type TeamId = z.infer<typeof teamIdSchema>;

const teamId: TeamId = teamIdSchema.parse("teamId");

// 型エラーになる。
const userId: UserId = teamId;

構造的部分型との対比として公称型という仕組みがよく挙げられます。公称型は逆に型の構造ではなく型の継承関係で代入可能性を決定します。TypeScriptを公称型風に扱うための方法は色々記事がありますが、この.brand()もそれらと似た方法で型に名前をつけることで、同じ構造同士の型を区別できるようにしています。

github.com

上記リンクのようなプロパティを型にのみ挿入することで、値は変えずに型の情報を増やすことで構造を変えています。

ZodはtRPCのようなフレームワーク的なライブラリではなく、提供しているものは比較的プリミティブなものなので、どのように活用するかが重要だと感じています。これについては今後も試行錯誤して、より良い活用方法を見つけて行きたいです。

終わりに

MEETの開発で行った技術的な取り組みについて紹介しました。

今後もゼストの技術的な取り組みをこのブログで紹介していきますので、読んでいただけたら幸いです。