GameWith Developer Blog

GameWith のエンジニア、デザイナーが技術について日々発信していきます。

Zod + Firestore で楽に使える汎用Converter : 型付けとジェネリクスの魔法 #GameWith #TechWith #Zod #Firestore

はじめに

こんにちは。GameWith のエンジニアの m です!

この記事はGameWith アドベントカレンダー2023 の5日目の記事です。 qiita.com

GameWith の一部のサービスでは Firebase の機能を活用していますが、
TypeScript を使って Firestore を扱う際にいくつかの課題に直面しました。
本記事では、これらの課題を解決した方法をご紹介します。

困っていたこと

  • スキーマとバリデーションの分離による対応漏れ
  • Firestore のドキュメントごとにバリデーション処理を作るのが手間
  • any や 型アサーションの使用で型が壊れて予期せぬエラーが起こる

Zod とは

TypeScript ファーストのスキーマ宣言・バリデーションライブラリです。
以下のような特徴があります。

  • 依存関係ゼロ
  • Node.js 及び最新の全てのブラウザで動作する
  • 小さい: 8kb minified + zipped
  • イミュータブル
  • 簡潔で連鎖可能なインターフェース
  • 機能的アプローチ: 解析し、検証しない
  • プレーンな JavaScript でも動作する

解決できたこと

  • スキーマにバリデーションルールを含んだ宣言が可能になった
  • ZodObjectが共通して parse() メソッドを持っているのでデータコンバーターを共通化して使い回しできた
  • Firestore から取得したデータや functions のリクエストパラメータの検証を安全に行えるようになった

サンプルコード

Converter

Firestore にはもともと FirestoreDataConverter という型保証の仕組みがあるのでZodObjectを使って共通化しました。
Zod の parse() は期待した型であるかの検証を行い、失敗時には ZodError を投げます。

import * as admin from "firebase-admin";
import { z } from "zod";
export const converter = <T extends z.AnyZodObject>(
  schema: T
): admin.firestore.FirestoreDataConverter<z.infer<T>> => ({
  toFirestore: (data: z.infer<T>): admin.firestore.DocumentData => {
    return schema.strict().parse(data);
  },
  fromFirestore: (
    snapshot: admin.firestore.QueryDocumentSnapshot<z.infer<T>>
  ): z.infer<T> => {
    return schema.strict().parse(snapshot.data());
  },
});

スキーマ宣言

Zod で Schema を定義しそれをもとに typeconverter を作成します。
バリデーションもここで設定することができます。

// スキーマを定義
export const userSchema = z.object({
  name: z.string(),
  age: z.number().min(0),
  isActivated: z.boolean(),
  createdAt: firestoreFieldValueOrTimestampSchema,
});

// スキーマをもとに型を作成
export type User = z.infer<typeof userSchema>;
/**
type User = {
  name: string;
  age: number;
  isActivated: boolean;
  createdAt: FirebaseFirestore.Timestamp | FirebaseFirestore.FieldValue;
}
*/

// スキーマをもとにコンバーターを作成
export const userConverter = converter(userSchema);

Timestampは以下のようにUnion型にするとうまく動作しました。

const firestoreFieldValueSchema = z.custom<admin.firestore.FieldValue>(
  (value) => value instanceof admin.firestore.FieldValue
);

export const firestoreTimestampSchema = z.custom<admin.firestore.Timestamp>(
  (value) => value instanceof admin.firestore.Timestamp
);

export const firestoreFieldValueOrTimestampSchema = z.union([
  firestoreFieldValueSchema,
  firestoreTimestampSchema,
]);

利用イメージ

withConverter() にコンバーターを渡すことで data は User型 であると期待されるため、誤った内容の場合はコンパイルエラーが発生するようになります。

const data = {
  name: "test",
  age: 20,
  isActivated: true,
  createdAt: admin.firestore.FieldValue.serverTimestamp(),
};
await db.collection("users").withConverter(userConverter).add(data);

また、 Zod で設定したバリデーションに引っかかる場合は、保存前にエラーが投げられます。

const data = {
  name: "test",
  age: -1, 
  // isActivated: true,
  createdAt: admin.firestore.FieldValue.serverTimestamp(),
};
await db.collection("users").withConverter(userConverter).add(data);
>  ZodError: [
>    {
>      "code": "too_small",
>      "minimum": 0,
>      "type": "number",
>      "inclusive": true,
>      "message": "Number must be greater than or equal to 0",
>      "path": [
>        "age"
>      ]
>    },
>    {
>      "code": "invalid_type",
>      "expected": "boolean",
>      "received": "undefined",
>      "path": [
>        "isActivated"
>      ],
>      "message": "Required"
>    }
>  ]

おわりに

共通コンバーターを作っておくことで、ドキュメントの種類が増えた際にも効率よく開発を行うことができました。 Firestoreの入出力だけでなくAPIへのリクエストパラメータも同じように共通化できたので、型周りで悩むことがなくなりよかったです。

GameWithではエンジニアを絶賛募集中です!
サーバーエンジニアやフロントエンジニアの方、AIに興味がある方や、Unityでの開発に興味がある方は是非GitHubの採用情報まとめをご覧ください!
カジュアル面談もお待ちしております! github.com