これは TECHSCORE Advent Calendar 2019 の1日目の記事です。
TL;DR
TypeScript で React と BFF の間の通信に型をつけてみたよ。
インターフェース定義は TypeScript で書いてるので、実装がそのままドキュメントになるよ。
React 側も BFF 側もきっちり型がつくので開発捗るよ。
ここに実装置いたから見てね。
はじめに
最近半年ほど TypeScript ばっかり書いてます。TypeScript で React App 書いて、TypeScript で BFF 書いて、「嗚呼、型があるって幸せだな」って思ってました。
でも唯一不満があります。React と BFF の間の通信。
fetchを使って書いてるんですが、
fetchで JSON 取ってくると戻り値は
anyになっちゃうんですよね、当たり前ですけど。
1 2 3 |
const response = await fetch(url); const json = await response.json(); # json の型は any !! |
これなんとかしたいな、
fetch使った通信でもちゃんと型つけたいな。
こういうときは Swagger かな、でも Swagger 面倒だな、文法覚えらんないし。
そもそも React も BFF も TypeScript で書いてるのに、わざわざ別の技術持ち込むのも嫌だな。
そういう訳で考えてみました。
方針
- React と BFF の間のインターフェースは TypeScript で記述する。つまり IDL = TypeScritpt。
- 関数呼び出しっぽく書けるようにする。
- クライアント側の実装はなるべく共通化する。
- サーバー側の実装はなるべく共通化する。
昔懐かしい CORBA の IDL とか、java.rmi のイメージです。
Stub と Skeleton、Express App は自動生成するのが王道ですが、まぁそれは別の機会に。
以下、図中の丸数字の順に作っていきます。
① インターフェース定義
ユーザーを操作するリモートインターフェースを考えます。
このインターフェースは
createや
readなどの関数を持っていて、それぞれの引数にどんな型を取りうるか、戻り値がどんな型なのか、ということを素直に TypeScript で記述するとこんな感じになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
type UserBffType = { create: (arg: { name: string; // 名前 age: number; // 年齢 }) => { id: number; // ID name: string; // 名前 age: number; // 年齢 createdAt: string; // 作成日時 updatedAt: string; // 更新日時 }; read: ...(略)... update: ...(略)... delete: ...(略)... }; |
各関数はオブジェクト型の引数を1つだけ取るようにしています。
・引数として複数の値を渡したい時はオブジェクトのプロパティとして渡してね。
・引数はJSON化されて
fetchでリクエストボディとして送信されるよ。
・
fetchのレスポンスが関数の戻り値として返ってくるよ。
という想定です。
② Stub
Stub はクライアント側の処理で、実際の通信を担う部分です。
要は
fetchを実行して、そのレスポンス(JSON)に型をつけたり、リクエストを型で制限できるようにします。
と、その前に、いくつか便利な定義をしておきます。
リモートインターフェース内で定義するリモート関数(上記の
createや
readなど)の型を
RemoteFunctionとし、
RemoteParameterでリモート関数の引数の型を取得できるようにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
/** * リモート関数 * * const foo: RemoteFunction = (arg: { id: number }) => ({ name: '' }); */ export type RemoteFunction = (arg: any) => any; /** * RemoteFunction の引数の型を取得する * * type Foo = (arg: { id: number }) => { name: string }; * type Arg = RemoteParameter<Foo>; * // type Arg = { id: number }; */ export type RemoteParameter<T extends RemoteFunction> = T extends ( arg: infer P ) => any ? P : never; |
さて、Stub(=実際に fetchするところ)を作ります。
postは「リモート関数と URL を指定すると、
fetchしてくれる非同期関数を返す」関数です。
Client で「
fetchしてくれる非同期関数」を呼び出すという想定です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
export const post = <T extends RemoteFunction>(input: RequestInfo) => async ( arg: RemoteParameter<T> ): Promise<ReturnType<T>> => { const response = await fetch(input, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(arg), }); if (!response.ok) { const message = `{response.status} (${response.statusText})`; throw new Error(message); } const json = await response.json(); return json; }; |
post関数は以下のように使います。
これによって URL とリモート関数の型を結びつけています。
1 2 3 |
const create = post<UserBffType['create']>('/user/create'); const read = post<UserBffType['read']>('/user/read'); ...(略)... |
VS Code で見ると、
createの引数と戻り値にちゃんと型がついていることがわかります。
ジェネリクスで型を指定しないと
anyになってしまいます。これは嬉しくない。
さらにリモート関数をひとまとめにしておきます。
1 2 3 4 5 |
export const UserBff: AsyncRemote<UserBffType> = { create, read, ...(略)... }; |
AsyncRemoteの定義はこんな感じ。インターフェース定義の各関数を非同期にしたもの、を作成するのに使います。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
/** * RemoteFunction を非同期にする * * type Foo = (arg: { id: number }) => { name: string }; * type Bar = AsyncRemoteFunction<Foo>; * // type Bar = (arg: {id:number}) => Promise<{name: string}> */ type AsyncRemoteFunction<T extends RemoteFunction> = ( arg: RemoteParameter<T> ) => Promise<ReturnType<T>>; /** * Remote の各メンバを非同期にする * * type Foo = { foo: (arg: { id: number }) => { name: string } }; * type Bar = AsyncRemote<Foo>; * type Bar = { foo: (arg: { id: number }) => Promise<{ name: string }> }; */ export type AsyncRemote<T extends { [P in keyof T]: RemoteFunction }> = { [P in keyof T]: AsyncRemoteFunction<T[P]>; }; |
AsyncRemoteを使うことで、 UserBffのプロパティに過不足がある場合にコンパイルエラーになってくれます。とても嬉しい。
③ Client
UserBff.createは通常の関数呼び出しのように使います。 とても簡単。
1 2 |
const cerateRes = await UserBff.create({ name: 'Alice', age: 20 }); console.log('create', cerateRes); |
④ ServerImpl(サーバー実装)
次にサーバー側でリモートインターフェースを実装します。
UserBffType['create']によって、引数と戻り値の型を指定しています。このおかげで、引数のプロパティを間違えたり、戻り値のプロパティに不足があったりした場合には、コンパイルエラーになります。これも嬉しい。
1 2 3 4 5 6 7 8 9 10 11 |
const create: UserBffType['create'] = arg => { // 中身の処理は適当 console.log('create', arg); const { name, age } = arg; const id = 1; const now = new Date(); const createdAt = `${now}`; const updatedAt = createdAt; return { id, name, age, createdAt, updatedAt }; }; ...(略)... |
⑤ Skeleton
サーバー側でリクエストを受け取って、サーバー実装を呼び出す箇所を作ります。
フレームワークによって実装方法が違いますが、以下は Express の例です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
export const requestHandler = <T extends RemoteFunction>( func: (arg: RemoteParameter<T>) => ReturnType<T> ) => ( req: express.Request, res: express.Response, next?: express.NextFunction ) => { const { body } = req; try { const response = func(body); res.json(response); } catch (err) { if (next) { next(err); } } }; |
「ServerImpl で実装した関数を Express のルーティングコールバックに変換する関数」を作っています。
ルーティングコールバック内では、Express のリクエストからボディを取り出して
funcに渡し、戻り値をレスポンスとして返します。
⑥ Express App
requestHandlerはこんな風に使います。
これによって URL とリモート関数の実装を結びつけています。
1 2 3 4 5 6 7 8 |
import express from 'express'; const app = express(); ...(略)... app.post('/user/create', requestHandler(UserBff.create)); app.post('/user/read', requestHandler(UserBff.read)); ...(略)... |
以上、すべての実装はこちらに置いてます。
章ごとの実装はこちら↓
① インターフェース定義
② Stub
③ Client
④ ServerImpl(サーバー実装)
⑤ Skeleton
⑥ Express App
記事中で触れなかった「サーバー側にエンドポイントがなかった場合はどうなるの?」とか「サーバー側で例外が発生した場合はどうなるの?」に関しても実装しています。
さいごに
TypeScript で React と BFF の間のインターフェースを定義してみました。
ジェネリクスでごにょごにょすることで、ユーザーがクライント側で呼び出す関数と、サーバー側で実装する関数に型をつけることができました。
インターフェース定義がそのままドキュメントになるし、型の不一致はコンパイルエラーになるしで、いいことづくめです。
最初に「Stub と Skeleton、Express App は自動生成するのが王道」と書きましたが、このくらいの量なら手で書いても大丈夫かな、型がないよりある方がいいしね。
なお、今回は React と BFF の間の通信に特化した内容でしたが、同じ手法で既存の Web API を呼び出す場合(つまり BFF と API の間の通信)に TypeScript で型をつけることもできます。これについてはまたの機会にでも。