FastifyのJSON SchemaからTypeScriptの型を生成する

10 min read読了の目安(約9000字

概要

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 }} といった型のレスポンスを許容してしまっています。

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