UPSIDER Tech Blog

ZodとOpenAPI Generatorで日付文字列をBrand型で扱う

UPSIDERでエンジニアをしている太田です。 (@Hide55832241)

この記事ではZodとOpenAPI Generatorを使用し、日付文字列をBrand型で扱う方法について紹介します。

TypeScriptを使用したフロントエンドのアプリケーション開発で日付をstring型で扱う際に困ることがあります。
日付をstring型として扱う際、APIから返却された未フォーマットの文字列と、UI用にフォーマットされた文字列を区別することが難しく、誤った表示や変換処理が発生しがちです。
またAPIリクエストでは正しいフォーマットで送信されているかどうかを保証できないため、不具合の原因になることがあります。

たとえばUIで表示する日付文字列が yyyy年MM月dd日 であった場合、この文字列をさらにフォーマットしようとすると想定外のエラーとなります。

// date-fnsのformatを想定
// APIレスポンスの日付文字列を想定
format(new Date('2024-01-20'), 'yyyy年MM月dd日') // Success

// 変換後の文字列を誤って指定
format(new Date('2024年01月20日'), 'yyyy年MM月dd日') // Error: Invalid Date

私たちが開発している 支払い.com でもこれらの課題を抱えており、Brand型を導入しました。
このプロダクトではOpenAPI Generatorを使用してコードを生成しています。そのため自動生成されるコードにBrand型をマッピングする必要がありました。
またFormやURLパラメータの検証にはZodを使用しています。Brand型をZodと組み合わせることでより扱いやすくなりました。

今回の記事では 支払い.com でBrand型を導入した知見を共有します。
大きくライブラリに依存した内容になりますが参考になれば幸いです。

設計

APIレスポンスの日付文字列を表示

// 表示コンポーネントではDate型で受け取ることで、フォーマットが必須であることを明確にする
const DateField = ({ value }: { value: Date }) => {
  return <p>{format(value, 'yyyy年MM月dd日')}</p> // formatはdate-fns想定
}

type ApiDateString = string & { __brand: 'ApiDateString' }

type Order = {
  // APIレスポンスの日付はBrand型で定義する
  shipmentDate: ApiDateString
}

const ShipmentDate = ({ shipmentDate }: { shipmentDate: Order['shipmentDate'] }) => {
  return <DateField value={new Date(shipmentDate)} />
}

ユーザーによる日付入力の値をAPIリクエストパラメータとして使用する

※ 以下の例ではuseStateを使用したサンプルですが、支払い.comではReact Hook Formを使用しています

const InputDate = (props: {
  value: string,
  onChange: (v: string) => void
}) => { /* 実装は省略 */ }

type ApiDateString = string & { __brand: 'ApiDateString' }

// APIリクエストパラメータの日付はBrand型で定義する
type PostParams = { shipmentDate: ApiDateString }

const postDate = (args: PostParams) => { /* 実装は省略 */}

const Form = () => {
  const [formData, setFormData] = useState<{ shipmentDate: string }>({ shipmentDate: '' })

  const handleSubmit = () => {
    // バリデーション
    if (!formData.shipmentDate) {
      return
    }
    postDate({
      ...formData,
      date: format(formData.shipmentDate, 'yyyy-MM-dd') as ApiDateString
    })
  }

  return (
    <form onSubmit={handleSubmit}>
      <InputDate
        value={formData.shipmentDate}
        onChange={
          (v) => setFormData(
            prev => ({ ...prev, shipmentDate: v })
          )
        }
      />
    </form>
  )
}

これらの設計のとおりBrand型を使用することで安全に日付文字列を扱えるようになりました。
実際にプロジェクトの導入する例を紹介します。

Zodを使用する

Brand型のZodスキーマを定義し、スキーマから型を得る

const API_DATE_STRING = z.string().brand('ApiDateString')
type ApiDateString = z.infer<typeof API_DATE_STRING> // string & z.BRAND<"ApiDateString">

// あとはZodを使用しない場合と同様に使用できる
type Order = {
  shipmentDate: ApiDateString
}

type PostParams = { date: ApiDateString }

Zodスキーマから得た型を使用しBrand型が定義できることはわかりましたが、まだ以下の問題があります。

  • ユーザー入力値の検証の処理を曖昧にしている
  • APIリクエストのパラメータを渡す際に、日付文字列の項目の詰替えが必要になっている
  • asを使用しBrand型にキャストしている

これらについてZodを使用し解決していきます。

ユーザー入力値の検証、型と値の変換

const FORM_SCHEMA = z.object({
  date:
    z
      .string() // 未入力状態など考慮し、入力はstringで受け付ける必要がある
      .refine(v => /* 文字列が日付として正しいか検証 */)
      .transform(v => format(v, 'yyyy-MM-dd')) // APIで扱う日付フォーマットに変換
      .pipe<API_DATE_SCHEMA> // 検証が完了し、フォーマットされた日付文字列はApiDateStringとして扱う
})

定義したスキーマを使用して検証し、後続処理で検証後の値を使用する。

  const handleSubmit = () => {
    // バリデーション
    const res = FORM_SCHEMA.safeParse(formData)
    if (!res.success) {
      return
    }
    postDate(res.data) // 検証後の日付文字列はApiDateString型となっているため、詰め替えが不要になった
  }

asの代わりにpipeを使うようにしただけではないかと言われるとそのとおりだと思いますが、検証、型と値の変換の定義を一箇所で行うことができ、シンプルで扱いやすくなったと感じていますがどうでしょうか?

transformでApiDateStringのフォーマットに変換すべきか

むずかしいところ
多くのユースケースでsubmitした値を使用し、APIリクエスト、その結果を使用してなんらかの後続処理を行うことを考えると有りだと思う。
しかしsubmitした値をQuery文字列へpushし、Query文字列から取得した値を使用しAPIリクエストを行うなど必ずしもApiDateStringへ変換すべきではないと思う。

FORM_SCHEMAから得られる型

検証前の値はstring型、検証後の値はApiDateString型として扱うことができる。
検証前は任意の値 (デフォルト値は空文字) を扱う必要があるのでstring型であることが望ましい。

  type Input = z.input<typeof FORM_SCHEMA> // string
  type Output = z.output<typeof FORM_SCHEMA> // string & z.BRAND<"ApiDateString">

OpenAPI Generatorで日付文字列をBrand型にマッピングする

APIスキーマからAPIのリクエストとレスポンスの型を自動生成することがあります。
OpenAPI Generatorを使用し、日付文字列をBrand型にマッピングする方法について説明します。

設定ファイル

typeMappings:
  date: ApiDateString

APIスキーマファイル

Order:
  type: object
  required:
    - shipmentDate
  properties:
    shipmentDate:
      type: string
      format: date

これで以下の型を生成できるようになります。

type Order = {
  shipmentDate: ApiDateString
}

ただし生成したファイルからApiDateStringをimportしていないため、エラーとなってしまいます。 生成したコードに追記するなどなんらかの方法でApiDateStringをimportするか、globalに定義することで解決できます。

今回はglobalに定義する方法を紹介します。

global.d.ts

// @/lib/zod/brandにApiDateStringを定義したものとする
declare type ApiDateString = import('@/lib/zod/brand').ApiDateString

また日時は以下の設定でコード生成できます。

設定ファイル

typeMappings:
  DateTime: UnixtimeNumber

APIスキーマファイル

Order:
  type: object
  required:
    - createdAt
  properties:
    createdAt:
      type: string
      format: date-time

生成されるコード

type Order = {
  createdAt: UnixtimeNumber
}

これでOpenAPI Generatorを使用したプロジェクトで日付文字列をBrand型で扱うことができるようになりました。

あとがき

日付の扱いは一見単純に思えても、プロジェクトが大きくなるにつれて複雑さが増していく部分です。
特にAPIとのやり取りやUIでのフォーマットなど、少しのミスで意図しない不具合を引き起こすことも少なくありません。
今回紹介したBrand型やZodやOpenAPI Generatorのような素晴らしいライブラリに助けられました。

もしこの記事が少しでも参考になれば幸いです。
フィードバックがあればお待ちしております。