TypeScript で Firebase の Realtime Database を利用すると、使い方次第でエラーが生じてしまう可能性があります。これは TypeScript の型チェックでは検知が難しいような undefined なプロパティを格納しようとしてしまうことがあるためです。この問題が起こるとデータ更新処理が失敗し、不整合な状態が発生してしまいます。 この記事では その問題を防ぐ方法を紹介します。
はじめに
こんにちは、NeWork 開発チームの加藤です。 Firebase の Realtime Database は使ったことがあるでしょうか?直感的に利用でき便利な NoSQL のサービスですが、意図しないところで更新に失敗することはありませんか?
この記事では、Realtime Database で undefined なプロパティが入り込むことによりエラーが発生する問題について、3 つの対策アプローチとそれぞれの長所・短所を解説します。特に最後に紹介するプロキシを用いた方法は、チーム開発での利用や更新処理が多い場合におすすめです。
環境
今回の記事の前提として、以下の環境を想定しています。
- TypeScript 5.8.2
- firebase-admin 11.11.1
背景
Firebase Realtime Database の仕様
Realtime Database ではデータ保存・更新の際に、更新対象のプロパティに undefined を指定するとエラーが発生します。公式ドキュメントにも、渡すことのできる形式について記載されています。
set には文字列、数値、ブール値、null、配列、または任意の JSON オブジェクトを渡すことができます。
TypeScript の Partial 型
データ更新のための関数を作成する際には、与える変数に柔軟性を持たせるために、Partial 型を利用できます。これにより、更新したいプロパティのみ指定できる関数を作成できます。
例えば以下のようにユーザーデータを更新できます。
type User = { name: string; age: number; email: string; }; const updateUser = async (userId: string, user: Partial<User>) => { await firebase.database().ref(`users/${userId}`).update(user); }; // 使用例1 updateUser("user1", { name: "Alice", age: 20 }); // 使用例2 updateUser("user2", { name: "Bob" });
上記の使用例 1、2 の場合であれば、undefined の値は含まれないため想定通りに機能します。 しかし Partial 型を使うと、undefined を含むデータも渡すことができてしまいます。これがエラーの原因となります。
エラーの例
以下のようなコードで undefined を含むデータを渡すと Realtime Database のエラーが発生します。
// 使用例3 updateUser("user3", { name: "Bob", age: undefined }); // エラー発生
使用例 2 の場合と異なり、undefined を格納しようとしたため、Realtime Database のエラーが生じてしまいました。また、Partial
この問題が発生してしまうと DB の更新処理が失敗してしまい、データの整合性を保つ上で問題となります。そのため今回は、この問題を改善する方法をいくつか紹介します。
解決策
解決策の案としてはいくつか考えられます。ここでは 3 つの案から比較検討を行いました。
全パターンの更新関数を用意する
まずは、undefined を許容しないようにする方法です。こちらは真っ先に思いつく方法ですが、全パターンの更新関数を用意する必要があります。例えば以下のように、name, age, email の全パターンの更新関数を用意することになります。
const updateUserName = async (userId: string, name: string) => { await firebase.database().ref(`users/${userId}/name`).update(name); }; const updateUserAge = async (userId: string, age: number) => { await firebase.database().ref(`users/${userId}/age`).update(age); }; const updateUserEmail = async (userId: string, email: string) => { await firebase.database().ref(`users/${userId}/email`).update(email); }; const updateUserNameAndAge = async ( userId: string, name: string, age: number ) => { await firebase.database().ref(`users/${userId}`).update({ name, age }); };
許容する更新パターンが少ない場合はこの方法でも問題ないかもしれません。しかし更新パターンが多い場合はメンテナンス性が悪くなります。
更新関数の中で undefined を除外する
以下のように、undefined を除外する関数を作成し、更新関数内で除去する処理を追加します。
const removeUndefined = <T extends Record<string, unknown>>( obj: T ): Partial<T> => { return Object.entries(obj).reduce( (acc: Partial<T>, [k, v]) => typeof v === "undefined" ? acc : { ...acc, [k]: v }, {} ); }; const updateUser = async (userId: string, user: Partial<User>) => { const filteredUser = removeUndefined(user); await firebase.database().ref(`users/${userId}`).update(filteredUser); };
この方法では、更新関数の中で undefined を除外することで、undefined を許容しつつエラーを回避できます。ただし、update 関数を作成するたびに removeUndefined 関数を呼び出す必要があります。そのため更新関数が多い場合は、メンテナンス性が悪くなるかもしれません。
JavaScript のプロキシを使う
最後に Realtime Database の関数をラップし、undefined を除外しつつ更新する方法を紹介します。
プロキシの概要
TypeScript(JavaScript)の Proxy は、オブジェクトの挙動をカスタマイズするための機能です。以下は公式ドキュメントの記載例です。
const target = { message1: "hello", message2: "everyone", }; const handler3 = { get(target, prop, receiver) { if (prop === "message2") { return "world"; } return Reflect.get(...arguments); }, }; const proxy3 = new Proxy(target, handler3); console.log(proxy3.message1); // hello console.log(proxy3.message2); // world
この例では、message2 へのアクセス時に値を書き換え world が帰ってくるようにしています。このように Proxy を使うことで、挙動を柔軟に変更できます。
プロキシを使った解決策の概要
Realtime Database の更新処理では、update や set メソッドに渡すデータから undefined を除外する必要があります。これをすべての更新関数に入れると2つめの案で記載の通り、コードが冗長になりメンテナンス性が低下します。
そこで、プロキシを使って update や set メソッドをラップし、データを渡す際自動的に undefined を除外する仕組みを作ります。これにより、開発者は undefined を気にせずコードを書けるようになります。
以下は、プロキシを使った解決策のイメージです。
const removeUndefined; // undefinedを除外する関数 // プロキシを使ってupdateメソッドをラップ const proxy = new Proxy(firebase.database().ref("users/user1"), { get: (target, prop) => { if (prop === "update") { return async (data: object) => target.update(removeUndefined(data)); } return target[prop]; }, }); // undefined を含むデータを渡してもエラーが発生しない proxy.update({ name: "Alice", age: undefined }); // 正常に動作
これにより、update 関数をラップし、undefined を除外しつつ更新できます。
実際の実装
実際にプロキシを使って Realtime Database の関数をラップし、undefined を除外しつつ更新する方法を実装してみます。 上記のコードを前提としつつ、以下の観点を追加して実装しています。
- ref のパスを users/user1 で固定せず、任意のパスに対応
- ref 以外のメソッドも利用可能
- 利用者が proxy を意識しないようにする
ここでは簡略化のために update 以外の set, push, child メソッドへの対応は省略します。また undefined を再起的に除去する関数についても 2 つめの方法で提示したものの拡張のため省略します。
// ラップ関数の定義: export class EnhancedRTDB { private db: Database; private proxy: Database; private static instance: EnhancedRTDB; constructor() { this.db = admin.database(); // Proxyを使用してメソッドの呼び出しをハンドリング this.proxy = new Proxy(this.db, { get: (target, prop) => this.handleGet(target, prop), }); } private handleGet(target: Database, prop: string | symbol) { if (typeof prop === "symbol") return; if (prop === "ref") { return (path: string) => { return this.createRefProxy(target.ref(path)); }; } // 他のメソッドの場合はそのまま返す const originalMethod = (target as unknown as Record<string, unknown>)[prop]; if (typeof originalMethod === "function") { return originalMethod.bind(target); } return originalMethod; } private createRefProxy(ref: admin.database.Reference) { return new Proxy(ref, { get: (target, prop) => this.handleRefGet(target, prop), }); } private handleRefGet( target: admin.database.Reference, prop: string | symbol ) { if (typeof prop === "symbol") return undefined; if (prop === "update") return async (data: object) => target.update(this.preProcess(data)); // 他のメソッドの場合はそのまま返す const originalMethod = (target as unknown as Record<string, unknown>)[prop]; if (typeof originalMethod === "function") { return originalMethod.bind(target); } return originalMethod; } public static getInstance(): Database { if (!EnhancedRTDB.instance) { EnhancedRTDB.instance = new EnhancedRTDB(); } return EnhancedRTDB.instance.proxy; } private preProcess(data: object): object { return this.isRecord(data) ? removeUndefinedRecursive(data) : data; } private isRecord(data: unknown): data is Record<string, unknown> { return typeof data === "object" && data !== null && !Array.isArray(data); } } // 再起的にundefinedを削除する関数 const removeUndefinedRecursive = <T extends Record<string, unknown>>( obj: T ): Partial<T> => { // 割愛 };
ラップした関数を使用する際のイメージは以下のようになります。
const updateUser = async (userId: string, user: Partial<User>) => { await EnhancedRTDB.getInstance().ref(`users/${userId}`).update(user); }; // 使用例 updateUser("user1", { name: "Alice", age: 20 }); updateUser("user2", { age: 30 }); updateUser("user3", { name: "Bob", age: undefined }); // エラー回避
プロキシを使って Realtime Database の関数をラップし、undefined を除外しつつ更新するようにしています。この方法では更新関数を作成する際に removeUndefinedRecursive 関数を呼ぶ必要がなくなります。そのためメンテナンス性が向上します。しかしプロキシ処理を挟んでいるため、パフォーマンスに影響する可能性があります。
各メソッドの解説
handleGet
メソッド- Database インスタンスのプロパティを取得する際に呼ばれるメソッドです。その後の処理を振り分けます。
ref
メソッドを呼び出すと、createRefProxy
メソッドを呼び出して、Reference インスタンスをラップします。- その他のメソッドはそのまま返します。
handleRefGet
メソッド- Reference インスタンスのプロパティを取得する際に呼ばれるメソッドです。その後の処理を振り分けます。
update
,set
,push
メソッドを呼び出すと、preProcess
メソッドを呼び出して、undefined を除外します。child
メソッドを呼び出すと、createRefProxy
メソッドを再度呼び出して、子 Reference インスタンスをラップします。- その他のメソッドはそのまま返します。
preProcess
メソッドremoveUndefinedRecursive
メソッドを呼び出して、undefined を除外します。
プロキシ処理の妥当性確認
参考として、この処理が正しいかの確認のためにテストコードも記載しておきます。
テストコード
const createMockRef = () => { const mockMethods = { update: jest.fn(), }; // child メソッドが呼ばれた時、新しいモック Ref を返すように設定 mockMethods.child.mockImplementation(() => createMockRef()); return mockMethods; }; jest.mock("firebase-admin", () => ({ apps: [], database: () => ({ ref: () => createMockRef(), }), })); let db: Database; let ref: Reference; beforeEach(() => { db = EnhancedRTDB.getInstance(); ref = db.ref("test"); }); // ref.getやref.keyの動作確認は省略 describe("preProcess が呼ばれていることを確認", () => { let input: { test: string; nullValue: null; undefinedValue: undefined; nestedObject: { valid: string; shouldBeRemoved: undefined; }; emptyString: ""; zero: number; }; beforeEach(() => { input = { test: "test", nullValue: null, undefinedValue: undefined, nestedObject: { valid: "valid", shouldBeRemoved: undefined, }, emptyString: "", zero: 0, }; }); const verifyProcessedData = ( targetMock: jest.Mock<void, [Record<string, unknown>]> ) => { // input には存在する undefined なプロパティが mock 引数にはないことを確認 expect(Object.keys(input)).toContain("undefinedValue"); expect(Object.keys(targetMock.mock.calls[0][0])).not.toContain( "undefinedValue" ); expect(Object.keys(input.nestedObject)).toContain("shouldBeRemoved"); const mockNestedObject = targetMock.mock.calls[0][0].nestedObject; expect(mockNestedObject).toBeInstanceOf(Object); expect( Object.keys(mockNestedObject as Record<string, unknown>) ).not.toContain("shouldBeRemoved"); }; test("正常系_ref.update 時に undefined なプロパティが削除されること", () => { const updateMock = jest.fn(); ref.update = updateMock; ref.update(input); verifyProcessedData(updateMock); }); // set, push についても同様のテストを行う(省略) });
各解決策の比較
それぞれの解決策の特徴をまとめます。
- 全パターンの更新関数を用意する
- シンプルで直感的
- 小規模なプロジェクトでは十分
- 更新パターンが多い場合は関数が膨大になりメンテナンス性が悪くなる
- 更新関数の中で undefined を除外する
- 比較的簡単に実装できる
- 各更新関数で除外処理を呼び出す必要があり、コードの重複が発生する。
- プロキシを使う
- 一度実装すれば、すべての更新処理で自動的に undefined を除外できるため、問題を意識しなくて良い。(オリジナルの sdk を利用しないように周知は必要です)
- 実装が複雑で理解しにくい。
- プロキシ処理を挟むため、パフォーマンスに若干影響する可能性がある。
まとめ
今回は TypeScript で Firebase の Realtime Database を使う際に発生する undefined プロパティの問題について、3つの解決策を紹介しました。
- 全パターンの更新関数を用意する
- 更新関数の中で undefined を除外する
- プロキシを使う
複数の開発者が利用する場合や、更新する対象・メソッドが多い場合はプロキシを利用する案がおすすめです。私たちは、更新系のメソッドが 10 を超えるほどあったので、プロキシを利用する方法を選びました。どの方法を選択するかは、状況に応じて検討してみてください。
以上、Firebase の Realtime Database で undefined なプロパティが入り込むことによりエラーが発生する問題について、その解決案を紹介しました。お役に立てれば幸いです。