hyper-ts で type-safe & stateless なExpressアプリケーションを構築する
tl; dr
TypeScriptとExpressを使ってアプリケーションを開発するにあたって、以下の課題がある。
- 現状、
@types/express
における型定義は厳密でない - リクエストハンドラの中でごちゃごちゃとやってしまい、ステートフルになりがち
こうした課題に対して、gcanti/hyper-ts
が有効である。
1. @types/express
における型定義はstrictでない
例として、Application
インターフェースの render
メソッドの型定義を見てみる。
render(view: string, options?: object, callback?: (err: Error, html: string) => void): void;
この型定義は厳密さに欠ける。まず、第二引数のoptions
の型は object
である。(cookie
メソッドのオプションは CookieOption
として定義されている。)
また、これは厳密さどうこうというより誤りだが、callback
の型は (err: Error | null, html: string) => void
が正しい(検証環境を用意するのは面倒だったので各自で要検証とする)。
Expressに限った話ではなく、JavaScriptライブラリに後付で型定義がつくられる場合はこうして曖昧な状態に留められることが多くある(要出典)。しかしながら、export type Send = (body?: any) => Response;
のようにany
が使われている箇所も多く、我々のような型定義をなるべく頑張りたい人々にはつらい状況である。
2. リクエストハンドラの中でごちゃごちゃとやってしまい、ステートフルになりがち
以下のコードは、拙作 https://anygma.site のためのRESTful HTTP APIのエンドポイントの実装である。ユーザのテキスト投稿を受け付けるために使われている。
app.post("/posts", async (req, res) => { const { title, body, createdById, password } = req.body as PostOption; if (!body) { return res.status(400).send("本文を入力してください"); } if (body.length > 1200) { return res.status(400).send("本文は1200字までです"); } if (title && title.length > 50) { return res.status(400).send("タイトルは50字までです"); } if (!createdById) { return res.status(400).send("ユーザidが提供されませんでした"); } const userRecord = await admin .auth() .getUser(createdById) .catch(error => { console.error({ error }); return null; }); if (!userRecord) { return res.status(400).send(`ユーザ: ${createdById}は存在しません`); } if (!password) { return res.status(400).send("パスワードが設定されていません"); } if (password.length < 8) { return res .status(400) .send("パスワードが短すぎます。8文字以上にしてください。"); } const passwordHash = await bcryptjs.hash(password, 10); const doc = await db.collection("/posts").add({ title, body, createdById, passwordHash } as FSPost); const snapshot = await doc.get(); const data = snapshot.data(); if (!data) { return res.status(500).send("投稿の作成に失敗しました"); } const { id } = doc; const bucket = admin.storage().bucket(); const bucketName = bucket.name; // 投稿を編集したらプレビュー画像も変更されるようにしたいので、プレビュー画像は投稿のidから一意に決まるようにする const fileName = `${id}.png`; const previewUrl = `https://storage.googleapis.com/${bucketName}/${fileName}`; const bufferOrError = await generatePreview(body).catch(err => { console.error({ err }); return new Error("プレビュー画像の生成に失敗しました"); }); if (bufferOrError instanceof Error) { return res.status(500).send(bufferOrError.message); } const tmpPath = join(tmpdir(), `${id}.png`); writeFileSync(tmpPath, bufferOrError); await bucket .upload(tmpPath, { metadata: { contentType: "image/png" }, public: true }) .catch(err => { console.log({ err }); return res.status(500).send("プレビュー画像のアップロードに失敗しました"); }); unlinkSync(tmpPath); await doc.set({ previewUrl } as Partial<Post>, { merge: true }); return res.send({ id, title, body, createdAt: snapshot.createTime ? snapshot.createTime.seconds : undefined, createdBy: extractDataFromUserRecord(userRecord), previewUrl } as Post); });
実装の詳細はどうでもよく、外部のデータストアへのアクセスやデータのバリデーションなど、同期的な/非同期的な/純粋な/副作用のある様々な処理が1つの関数の中に記述されてしまっている。これではテスタビリティもへったくれもない。幸い、ExpressにはMiddlewareという仕組みがあり、これを使うことである程度処理を分割してつなげることができる。このあと紹介する hyper-ts
というライブラリでは、公式の型定義よりもさらに強力な型定義を武器に、このMiddlewareを中心にステートレスなアプリケーションを記述するための仕組みを提供してくれている。
hyper-tsの紹介
公式のREADMEによると、
hyper-ts is an experimental middleware architecture for HTTP servers written in TypeScript.
Its main focus is correctness and type-safety, using type-level information to enforce correct composition and abstraction for web servers.
hyper-tsは type-safety を重視した middleware architecture とのことである。以下は拙作 https://tweet2img.web.app において、ツイートのURLを送るとpng形式の画像が返ってくるAPIエンドポイントの実装である。
// 前略 function badRequest<E = never>( message: string ): H.Middleware<H.StatusOpen, H.ResponseEnded, E, void> { return pipe( H.status(H.Status.BadRequest), H.ichain(() => H.closeHeaders()), H.ichain(() => H.send(message)) ); } function notFound<E = never>( message: string ): H.Middleware<H.StatusOpen, H.ResponseEnded, E, void> { return pipe( H.status(H.Status.NotFound), H.ichain(() => H.closeHeaders()), H.ichain(() => H.send(message)) ); } function serverError<E = never>( message: string ): H.Middleware<H.StatusOpen, H.ResponseEnded, E, void> { return pipe( H.status(H.Status.ServerError), H.ichain(() => H.closeHeaders()), H.ichain(() => H.send(message)) ); } const sendError = ( err: TweetURLError | typeof FailedToGenerateImage ): H.Middleware<H.StatusOpen, H.ResponseEnded, never, void> => { switch (err) { case 'TweetNotFound': return notFound(TweetNotFoundMessage); case 'InvalidArguments': return badRequest(InvalidRequestMessage); case 'FailedToGenerateImage': return serverError(InternalServerErrorMessage); } }; // returns a middleware validating `req.param.tweetURL` const m = t.interface({ tweetURL: TweetURL }); const decoded = H.decodeQuery(m.decode); const parseTweetURLFromQueryMiddleware = pipe( decoded, H.mapLeft<t.Errors, ServerError | TweetURLError>(() => InvalidArguments) ); const checkIfTheTweetExistsMiddleware = (query: { tweetURL: TweetURL; }): H.Middleware< H.StatusOpen, H.StatusOpen, ServerError | TweetURLError, ValidatedTweetURL > => H.fromTaskEither( pipe( checkIfTheTweetExists(query.tweetURL, { TWITTER_BEARER_TOKEN }), TE.chain(exists => exists ? TE.right(query.tweetURL as ValidatedTweetURL) : TE.left(TweetNotFound) ) ) ); const mapTweetURLToImage = ( url: ValidatedTweetURL ): H.Middleware< H.StatusOpen, H.StatusOpen, ServerError | TweetURLError, Buffer > => H.fromTaskEither( pipe( generateImage(url), TE.mapLeft(e => FailedToGenerateImage) ) ); const respondWithImage = ( imgBuffer: Buffer ): H.Middleware< H.StatusOpen, H.ResponseEnded, ServerError | TweetURLError, void > => pipe( H.status(H.Status.OK), H.ichain(() => H.header('Content-disposition', `inline; filename="${new Date()}.png"`) ), H.ichain(() => H.contentType(H.MediaType.imagePNG)), H.ichain(() => H.closeHeaders()), H.ichain(() => H.send(imgBuffer)) ); const getImage = pipe( parseTweetURLFromQueryMiddleware, H.ichain(checkIfTheTweetExistsMiddleware), H.ichain(mapTweetURLToImage), H.ichain(respondWithImage) ); app.get( '/img', toRequestHandler( pipe( getImage, H.orElse(sendError) ) ) );
Middleware<I, O, L, A>
3~44行目では、普段 res.status(HTTP_ERROR_STATUS_CODE).send(ERROR_BODY)
などとしてエラーを返している処理をMiddlewareとして定義している。ここでMiddlewareの型 H.Middleware<I, O, L, A>
は以下のようなセマンティクスを持っている。
-
I
: ユーザエージェントとHTTPサーバの通信状態を表すConnection
表現を用いて、このミドルウェアが呼びだれたときの通信状態を表現した型。この例ではH.StatusOpen
が使われており、レスポンスの status-line が送信可能な状態を表している。 -
O
:I
の Input に対して Output (ミドルウェアの処理が終わった時の通信状態)を表現した型。この例ではH.ResponseEnded
が使われており、レスポンスヘッダが送信可能な状態(i.e. レスポンスボディのストリーミングが開始されていない状態)を表している。 -
L
: Eitherモナドの左辺値の型。この例ではnever
が指定されており、エラー表現を表している。 -
A
: Eitherモナドの右辺値の型。この例ではvoid
が指定されており、エラーがなかった場合に次のミドルウェアに渡る値の型を表している。
ここで、36行目から44行目にかけて、パターンマッチングのようにしてミドルウェアの返しうるエラーの種類毎にエラーメッセージを send
している点に注目してほしい。リクエストハンドラをミドルウェアの合成で表現することで返しうるエラーが明確となり、このような記述が可能になっている。クライアントサイドでTypeScriptを採用していれば、この型を使いまわしてエラーの種類ごとにエラーメッセージを出し分けることもできる。
また、I
そして O
を定義しておくことで、リクエストヘッダをクローズしたあとに値をセットしようとしたり、最終的に send
するMiddlewareのあとに別のMiddlewareを繋げたりしようとしたことをコンパイル時に検出することができる。
io-ts を使ってリクエストbodyやクエリパラメータに実行時型検査をする
46行目から55行目では、実行時型検査をするためのライブラリio-ts
を使って、URLのクエリパラメータをパースするミドルウェアを定義している。io-ts
を使うことで、プログラムの実行時に期待する型(今回の例では string
の亜種である NonEmptyString
)と異なる型の値を受け取った場合でも、left(ERROR_INSTANCE)
を返すなど適切に処理することが出来るようになる。
最後に
-
hyper-tsはfp-tsのエコシステムに乗っかっているが、この記事の公開時点でhyper-tsの最新リリースはfp-tsの新しいメジャーバージョンである2.xに対応していないので注意されたし。
- 2.xではAPIにいくつかbreaking changesがあり、いまから使い始めるのであれば2.xを採用するべき。
-
0.5.0
ブランチは2.xに対応している(現在の最新リリースは0.4.0
)。- この記事に書かれたコードは0.5.0のhyper-tsのAPIを使っているため合わせて注意されたし。
-
今回登場した
Either
などのモナドが何なのかよくわかっていなくても、静的型付けを頑張りたいTSプログラマにはhyper-tsをお勧めできる。- TypeScriptでは現状、純粋な関数として扱っている関数の中で
console.log
などの純粋でない関数を呼び出してもコンパイルエラーにならない(HaskellやPureScriptなど、言語仕様としてモナドを想定している静的型付け言語の一部ではエラーになる)。これはfp-tsを使っても同様で、fp-tsを使ったからといって全然コンパイルが通らなくてつらいということはない(明らかに純粋な関数がチェインしているところに副作用のある処理を書くことに対して多少の罪悪感は生まれる)。モナドを「便利な道具」として、ぜひ気軽な気持ちで使い始めてほしい。
- TypeScriptでは現状、純粋な関数として扱っている関数の中で
Top comments (0)