💨

FastifyのJSON SchemaからTypeScriptの型を生成して幸せになる

2021/02/25に公開
5

追記

より簡潔に型を定義できるようにアップデートが入ったようです!(少なくともv3.24.1以降)。
https://www.fastify.io/docs/latest/TypeScript/#json-schema

こちらの私の記事はほぼ用無しですね。


※当記事の内容はすでに古く、最新のFastifyでは動作しない可能性があります。ご注意ください。
最新版のFastifyではSchemaからの型生成をより簡潔に行うことができるので、公式Documentをご参考にください。

概要

FastifyのJSON SchemaからTypeScriptの型を生成したらNode.jsでも比較的型安全なAPIを実現でき幸せになれるのではと思いがんばりました。

サンプルコード: GitHub

Fastifyの紹介

最近Expressの代替としてよく名前を聞くFastify。パフォーマンスの良さやビルトインのバリデーション機能が売りとされていますが、それらの売りを支えているのがJSON Schemaです。

Fastifyでは、事前にAPIのリクエストやレスポンスのJSON Schemaを定義してフレームワークに渡しておくことで、

  • Serializationの高速化
  • 簡易的なバリデーション

をフレームワーク側で行ってくれます。とってもありがたいですね。
ref: どうしてFastifyは生のNode.jsより速いの?

また3rdパーティのライブラリを使うことでJSON SchemaからSwaggerを吐き出すこともできます。これまた便利なのでいつか別の記事で紹介します。

Schemaの定義方法

公式ドキュメントを参考にすると、TypeScriptを使ったSchema定義はこんな感じになります。

import * as Fastify from 'fastify';

const server = Fastify.fastify({ logger: true })

type Query = {}
type Params = {
  id: number
}
type ReqBody = {}
type Headers = {}


const schema: Fastify.RouteShorthandOptions = {
  schema: {
    params: {
      type: 'object',
      properties: {
        id: {
          type: 'number'
        }
      }
    },
    body: {
      type: 'object',
      properties: {
        user: {
          type: 'object',
	  properties: {
	    id: { type: 'number' },
	    name: { type: 'string' },
	  }
        }
      }
    },
  }
}

const handler = async (request, _response) => {  
  if (request.params.id % 2 === 0) {
    reply.code(200).send({
      user: {
        id: 1,
        name: "Tom"
      }
    })
  } else {
    reply.code(404).send({
      message: "User Not Found"
    })
  }
}

server.get<Query, Params, ReqBody, Headers>("/users/:id", schema, handler)

定数schemaに代入されているのが、JSON Schemaとして利用される値です。

しかし、このままだとSchemaの情報とget関数に渡しているTypeScriptの型情報が一致していなくてもトランスパイルが通ってしまいます。 前述の通りFastifyのSchemaはswaggerとして吐き出すことも可能なので、トランスパイル時に極力Schemaの不一致によるエラーを弾いて実行時エラーを防ぎたいのが人情というものです。

コードを見ていると

  • optsと型の2箇所にSchema情報が散らばっていること
  • responseの型検査が行われていないこと

の2点がよくないように思えます。これらを解決していきましょう。

TypeBoxで静的解析可能なJSON Schemaに書き換える

FastifyデフォルトのSchemaの書き方では、JSON SchemaをTypeScriptで静的解析することができません。そこで@sinclair/typeboxを使います。TypeBoxはJSON SchemaをTypeScriptで静的型に解結可能にする素敵なライブラリです。

TypeBox is a type builder library that creates in-memory JSON Schema objects that can be statically resolved to TypeScript types. (https://github.com/sinclairzx81/typebox より引用)

[和訳] TypeBox は、インメモリ JSON Schema オブジェクトを作成するタイプビルダーライブラリで、静的に TypeScript 型に解決することができます。

先程定義したschemaをTypeBoxを利用したものに書き換えていきましょう。

import * as Fastify from 'fastify';
// New!
import { Static, Type } from '@sinclair/typebox';

const server = Fastify.fastify({ logger: true })


type Query = {}
type Params = {
  id: number
}
type ReqBody = {}
type Headers = {}

// New!
const paramsSchema = Type.Object({
  id: Type.Number()
})

// New!
const responseSchema = {
  200: Type.Object({
    user: Type.Object({
      id: Type.Number(),
      name: Type.String(),
    })
  }),
  400: Type.Object({
    message: Type.String()
  })
}

// New!
const schema: Fastify.RouteShorthandOptions = {
  schema: {
    params: paramsSchema,
    response: responseSchama,
  }
}

// Modify: 第二引数のschemaを書き換え
const handler: Handler<Request, ResponseBody> = async (request, _response) => {
  if (request.params.id % 2 === 0) {
    reply.code(200).send({
      user: {
        id: 1,
        name: "Tom"
      }
    })
  } else {
    reply.code(404).send({
      message: "User Not Found"
    })
  }
}

server.get<Query, Params, ReqBody, Headers>("/users/:id", schema, handler)

SchemaからHandlerに渡すための型を生成する

先程schemaをTypeScriptの型に解結可能にしたので、これを使ってFastifyのHandler(getやpost関数など)にschemaの型情報を渡します。

FastifyにおけるRequestはFastify.Requestという型で表現されているので、これをベースに自前の型をつくっていきましょう。読みやすくするために、この抽象度の高い型は別のファイルに切り出します。

import * as Fastify from 'fastify';
import { FastifyRequest } from 'fastify';
import { Static } from '@sinclair/typebox';

type CustomRequest<
  Params,
  Query,
  ReqBody,
  Headers extends Fastify.RequestHeadersDefault
> = Omit<FastifyRequest, "params" | "query" | "body" | "headers">
& { params: Params, body: ReqBody, query: Query, headers: Headers }

type Handler<
  Request extends FastifyRequest,
> = (request: Request, reply: Fastify.FastifyReply) => void

export { Handler, CustomRequest }

CustomRequestという命名が些か微妙ですが気にせずいきましょう。
定義したHandler, CustomRequestという型を先程のAPIの実装ファイルから呼び出します。

import * as Fastify from 'fastify';
import { Type, Static } from '@sinclair/typebox';

// New!!
import { Handler, CustomRequest } from './types';

const server = Fastify.fastify({ logger: true })

const paramsSchema = Type.Object({
  id: Type.Number()
})

const responseSchema = {
  200: Type.Object({
    user: Type.Object({
      id: Type.Number(),
      name: Type.String(),
    })
  }),
  400: Type.Object({
    message: Type.String()
  })
}

const schema: Fastify.RouteShorthandOptions = {
  schema: {
    params: paramsSchema,
    response: responseSchema,
  }
}

type Params = Static<typeof paramsSchema>;
type Query = {}
type ReqBody = {}
type Headers = {}
type Request = CustomRequest<Params, Query, ReqBody, Headers>;

const handler: Handler<Request> = async (request, _response) => {
  if (request.params.id % 2 === 0) {
    reply.code(200).send({
      user: {
        id: 1,
        name: "Tom"
      }
    })
  } else {
    reply.code(404).send({
      message: "User Not Found"
    })
  }
}

server.get("/users/:id", schema, handler)

これでリクエストに対してschemaから生成した型で型検査を行うことができました。
今回は読みやすさの観点から省略しましたが、Paramsと同様の形式で、Query, ReqBody, Headersにもschemaから型を渡すことができます。

レスポンスについてもschemaから型生成

リクエスト同様にレスポンスについてもschemaから渡した型で型解析可能にしましょう。

リクエストのときよりも少々手を加える量が増えますが、やることは3つです。

  1. リクエストと同様にschemaからレスポンス用の型を生成するための抽象的な型を定義
  2. 現状のコードでは、reply.sendという関数にレスポンスを渡している。この関数はユーザー側で型を与えることができないので、return文を使った書き方に書き換える。
  3. エンドポイントから返却されるステータスコードの種類も型解析に含めてしまいたいため、StatusCodeもreturn文に載せられるようにする。

まずはSchemaからFastifyのHandlerに渡すための型を定義していきます。

import * as Fastify from 'fastify';
import { FastifyRequest } from 'fastify';
import { Static } from '@sinclair/typebox';

type CustomRequest<
  Params,
  Query,
  ReqBody,
  Headers extends Fastify.RequestHeadersDefault
> = Omit<FastifyRequest, "params" | "query" | "body" | "headers">
& { params: Params, body: ReqBody, query: Query, headers: Headers }

type CustomResponse<StatusCode, ResponseSchema> = {
  code: StatusCode,
  body: Static<ResponseSchema>
}

// Modify:
// - 型パラメータにResponseBodyを追加。
// - 返り値をvoidからPromise<ResponseBody>に変更
type Handler<
  Request extends FastifyRequest,
  ResponseBody
> = (request: Request, reply: Fastify.FastifyReply) => Promise<ResponseBody>

export { Handler, CustomRequest, CustomResponse }

つづいてResponseBodyとStatusCodeを静的解析可能なものにするために、return文とaddHookをつかって一工夫していきます。

import * as Fastify from 'fastify';
import { Type, Static } from '@sinclair/typebox';
import { Handler, CustomRequest, CustomResponse } from './types';

const server = Fastify.fastify({ logger: true })

// New! 受け取ったpaylodからcodeだけ抜き取ってreply.codeに入れる。
server.addHook(
  "preSerialization",
  async(
    _request,
    reply,
    payload: { code: number, body: any }
  ) => {
  reply.code(payload.code)
  return payload.body
})


const paramsSchema = Type.Object({
  id: Type.Number()
})

const responseSchema = {
  200: Type.Object({
    user: Type.Object({
      id: Type.Number(),
      name: Type.String(),
    })
  }),
  404: Type.Object({
    message: Type.String()
  })
}

const schema: Fastify.RouteShorthandOptions = {
  schema: {
    params: paramsSchema,
    response: responseSchema,
  }
}

type Params = Static<typeof paramsSchema>;
type Request = CustomRequest<Params, {}, {}, {}>;

// New!: StatusCodeとResponseBodyを静的型に変換
type StatusCode = keyof typeof responseSchema;
type ResponseBody = CustomResponse<StatusCode, typeof responseSchema[StatusCode]>;

// Modify
// - Handler型にResponseBodyを渡す
// - reply.code(200).send(hogehoge)でレスポンスを送信していたのを、return分でresponse情報を返す形式に変更
const handler: Handler<Request, ResponseBody> = async (request, _response) => {
  if (request.params.id % 2 === 0) {
    return {
      code: 200,
      body: {
          user: {
            id: 1,
            name: "Tom" 
          }  
      }
    }
  } else {
    return {
      code: 404,
      body: {
        message: "Not Found"
      }
    }
  }
}

server.get("/users/:id", schema, handler)

これで完成です。正確なサンプルコードは以下に載せていますのでご覧ください。
GitHub

課題点

ここまで書いてきたコードですが、まだいくつかの課題点が残っております。

1. QueryやHeadersなどの型が空のときに、{}を渡さなければならず冗長

説明は不要だと思います。冗長ですよね。

2. Responseの型が曖昧

Responseの型が実はまだ曖昧なままです。以下に理想系と現状を記します。

理想形

  • codeが200の場合、bodyは{ user: { id: number, name: string }}
  • codeが404の場合、bodyは{ message: string }

現状

  • statusCodeは 200 | 404 を許容する
  • bodyは { user: { id: number, name: string }} | { message: string } を許容する

言い換えると { code: 200, message: string } や { code: 404, user: { id: number, name: string }} といった型のレスポンスを許容してしまっています。

本当は理想形のような形にしたかったのですが、私の型パズル力と休暇時間ではいまのところ解決できていません。私が行っている開発で使う分には十分であると判断できたためこのまま利用していますが、どなたかスマートに解決できた方がいたら教えていただけるとすごく嬉しいです。

Discussion

五所 和哉 (MonCargo CTO)五所 和哉 (MonCargo CTO)

TypeBox 便利そうですね!ありがとうございます。

  1. QueryやHeadersなどの型が空のときに、{}を渡さなければならず冗長

デフォルト型引数でいけませんかね?

type CustomRequest<
  Params = {},
  Query = {},
  ReqBody = {},
  Headers extends Fastify.RequestHeadersDefault = Fastify.RequestHeadersDefault
> = Omit<FastifyRequest, "params" | "query" | "body" | "headers">
& { params: Params, body: ReqBody, query: Query, headers: Headers }

ただ ReqBody のみほしい時に CustomRequest<{}, {}, Body> などとしないといけないので、構造体で型を渡したほうが良いかもしれませんが...

  1. Responseの型が曖昧

やや複雑 & マニュアル的ですが、ステータスコード毎にレスポンスを定義する型を一回作ってあげればいけそうです!

// types.ts

type GetCustomResponseFromStatusCode<
  StatusCode extends number,
  ResponseSchema
> = {
  code: StatusCode
  body: ResponseSchema extends { [ken in StatusCode]: infer T } ? T : never
}

type CustomResponse<ResponseSchema> =
  | GetCustomResponseFromStatusCode<200, ResponseSchema>
  | GetCustomResponseFromStatusCode<401, ResponseSchema>
  | GetCustomResponseFromStatusCode<404, ResponseSchema>
  // ... other status codes follow

手元で動かした感じだとチェックできてそうです。

一点だけ、 body が必要無いときに { body: undefined } としないといけないのが難点ですが、まあ body を返さない場合はあまり多くないので許容かなと思いました。

moss0321moss0321

コメントありがとうございます!!

ただ ReqBody のみほしい時に CustomRequest<{}, {}, Body> などとしないといけないので、構造体で型を渡したほうが良いかもしれませんが...

おっしゃているとおり、TypeScriptで任意の型引数だけ省略することができないので、デフォルト型引数を使っても冗長さはあまり改善できないなぁ...と思っていました....。

やや複雑 & マニュアル的ですが、ステータスコード毎にレスポンスを定義する型を一回作ってあげればいけそうです!

おおお!これはとってもありがたいです!
自分のプロジェクトに導入してみようと思います!記事にしてよかった....。
感謝です!

MOKYNMOKYN

記事とても参考になりました。ただ、最新バージョン(v3.24.1)のfastifyですと型定義が変更になっているためか記事の内容そのままでは動きませんでした。

公式ページを見てみるとtypeboxでの記述方法が書かれており、より簡潔に書けるようになったようです。
ステータスコード毎にレスポンスを定義する型は無理そうでしたが、任意の引数だけ記述できるのでスマートです。

情報共有まで。

https://www.fastify.io/docs/latest/TypeScript/#json-schema

moss0321moss0321

@MOKYN
ご共有ありがとうございます!だいぶお手軽に書けるようになっていましたね!
この記事はもう用無しそうでしたので、トップに注意書きを追記しました。