Skip to main content

第19章:TypeScriptで型安全CRUD DTO・Converter・ガード🧱✨

この章のゴールはシンプルです👇😄 「Firestoreに保存する形」と「UIで使う形」を分けて、読み書きの事故を減らすこと! そして、読み出しのたびに「えーい!as で型アサーション!」を卒業します🎓✨

なお、Firebase JS SDK は 12.8.0(2026-01-14) まで公式リリースノートで確認できます。(Firebase) Firestore の Converter まわりも公式APIに載ってるので、そこに寄せた実装をします💪(Firebase)


読む📚✨ なんで DTO と Converter が必要なの?

Firestore は DB側でスキーマ(型)を強制しません。なので「昔のアプリが書いた古い形のデータ」や「Converter無しの別クライアントが書いたデータ」が混ざる可能性があります😇 公式も「変換時に “復旧するか / エラーにするか” を決めてね」と言っています。(Firebase)

そこで登場するのがこの3点セット👇

firebase_firestore_struncture_ts_study_019_01_dto_bridge.png

  • DTO(DbModel):Firestore に保存する “生の形” 🧱

  • UIモデル(AppModel):React が気持ちよく使える形 💖

  • Converter(withConverter):読み書き時に自動で変換してくれる橋🌉

    • Converter は「AppModel ↔ DbModel」を自動変換するための仕組みとして定義されています。(Firebase)

手を動かす🖐️🔥 DTO・Converter・CRUD を作る

ここでは例として「posts」と「comments」を想定します📝 (あなたのロードマップの「日報/記事/コメント」の “記事/コメント” にそのまま対応できます👌)

0) Firebase を入れる📦

Firebase のWebセットアップは npm 前提の手順が公式にあります。(Firebase)

npm install firebase

1) 型を2つに分ける🧩 UI用と保存用

ポイントはここ👇😄

firebase_firestore_struncture_ts_study_019_02_timestamp_vs_date.png Firestore は Timestamp を返すけど、UI は Date の方が扱いやすい!みたいなズレを吸収します。

  • UI用(AppModel):Date を使う
  • 保存用(DbModel / DTO):Timestamp を使う
// src/features/posts/types.ts
import type { Timestamp, FieldValue } from "firebase/firestore";

// UIで使う形(Reactが嬉しい)
export type Post = {
id: string;
title: string;
body: string;
authorId: string;
authorName: string;
createdAt: Date;
updatedAt: Date;
commentCount: number;
};

// Firestoreに保存する形(DTO)
export type PostDTO = {
title: string;
body: string;
authorId: string;
authorName: string;
createdAt: Timestamp;
updatedAt: Timestamp;
commentCount: number;
};

// 書き込み時だけ許したい形(serverTimestamp など)
export type PostWrite = Omit<Post, "id" | "createdAt" | "updatedAt"> & {
createdAt: Date | FieldValue;
updatedAt: Date | FieldValue;
};

2) ガードを書く🛡️ Firestoreは型を保証しないので最後は自分で守る

「Converter があるから安全!」…と言いたいけど、DBに変なデータが入ってたら終わりです😇 だから fromFirestore の中で検査します。

firebase_firestore_struncture_ts_study_019_03_data_guard.png

// src/features/posts/guards.ts
import { Timestamp } from "firebase/firestore";
import type { PostDTO } from "./types";

function isNonEmptyString(v: unknown): v is string {
return typeof v === "string" && v.trim().length > 0;
}

function isNumber(v: unknown): v is number {
return typeof v === "number" && Number.isFinite(v);
}

function isTimestamp(v: unknown): v is Timestamp {
return v instanceof Timestamp;
}

export function assertPostDTO(data: any): asserts data is PostDTO {
if (!data || typeof data !== "object") throw new Error("PostDTO: not an object");

if (!isNonEmptyString(data.title)) throw new Error("PostDTO.title invalid");
if (!isNonEmptyString(data.body)) throw new Error("PostDTO.body invalid");
if (!isNonEmptyString(data.authorId)) throw new Error("PostDTO.authorId invalid");
if (!isNonEmptyString(data.authorName)) throw new Error("PostDTO.authorName invalid");

if (!isTimestamp(data.createdAt)) throw new Error("PostDTO.createdAt invalid");
if (!isTimestamp(data.updatedAt)) throw new Error("PostDTO.updatedAt invalid");

if (!isNumber(data.commentCount)) throw new Error("PostDTO.commentCount invalid");
}

公式も「Firestore はスキーマを強制しないので、変換時にどう扱うか決めてね」と明記しています。(Firebase) ここでは “壊れてたら落とす” を選びました(最初はこれが一番気づけて安全)💥


3) Converter を作る🌉✨ withConverter の本体

firebase_firestore_struncture_ts_study_019_04_converter_logic.png

Converter の型定義(toFirestore / fromFirestore)は公式に載っています。(Firebase) また、merge を使うなら PartialWithFieldValue を扱える形にしてね という注意も公式にあります。(Firebase)

// src/features/posts/converter.ts
import {
Timestamp,
serverTimestamp,
type FirestoreDataConverter,
type QueryDocumentSnapshot,
type SnapshotOptions,
type WithFieldValue,
type PartialWithFieldValue,
} from "firebase/firestore";

import type { Post, PostDTO, PostWrite } from "./types";
import { assertPostDTO } from "./guards";

function omitUndefined<T extends Record<string, any>>(obj: T): T {
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as T;
}

function toDbTimestamp(v: unknown): any {
// Date → Timestamp
if (v instanceof Date) return Timestamp.fromDate(v);
// serverTimestamp() など FieldValue はそのまま通す
return v;
}

export const postConverter: FirestoreDataConverter<Post, PostDTO> = {
toFirestore(model: WithFieldValue<Post> | PartialWithFieldValue<Post>) {
const m: any = model;

// 重要:undefined を Firestore に投げない(投げるとエラーになりがち)
return omitUndefined({
title: m.title,
body: m.body,
authorId: m.authorId,
authorName: m.authorName,
createdAt: toDbTimestamp(m.createdAt),
updatedAt: toDbTimestamp(m.updatedAt),
commentCount: m.commentCount,
}) as any;
},

fromFirestore(snapshot: QueryDocumentSnapshot, options?: SnapshotOptions): Post {
const data = snapshot.data(options);

// ここで “壊れてたら即発見” 💥
assertPostDTO(data);

return {
id: snapshot.id,
title: data.title,
body: data.body,
authorId: data.authorId,
authorName: data.authorName,
createdAt: data.createdAt.toDate(),
updatedAt: data.updatedAt.toDate(),
commentCount: data.commentCount,
};
},
};

// 作成時に使う便利ヘルパ(UIからはこれだけ触ればOKにする)
export function buildNewPostWrite(input: {
title: string;
body: string;
authorId: string;
authorName: string;
}): PostWrite {
return {
title: input.title,
body: input.body,
authorId: input.authorId,
authorName: input.authorName,
createdAt: serverTimestamp(),
updatedAt: serverTimestamp(),
commentCount: 0,
};
}

4) 型安全 CRUD を “関数” に閉じ込める📦✨

React コンポーネントが Firestore の細かい事情を知り始めると、すぐ散らかります😵 なので Repository(読み書き関数)に寄せます

firebase_firestore_struncture_ts_study_019_05_repository_pattern.png

// src/features/posts/repository.ts
import {
collection,
doc,
getDoc,
getDocs,
query,
orderBy,
limit,
setDoc,
deleteDoc,
type Firestore,
} from "firebase/firestore";

import type { Post } from "./types";
import { postConverter, buildNewPostWrite } from "./converter";

export function postsCollection(db: Firestore) {
return collection(db, "posts").withConverter(postConverter);
}

export async function createPost(db: Firestore, input: {
title: string;
body: string;
authorId: string;
authorName: string;
}): Promise<string> {
const col = postsCollection(db);
const ref = doc(col); // 自動ID
await setDoc(ref, buildNewPostWrite(input) as any);
return ref.id;
}

export async function getPost(db: Firestore, postId: string): Promise<Post | null> {
const ref = doc(postsCollection(db), postId);
const snap = await getDoc(ref);
return snap.exists() ? (snap.data() as Post) : null;
}

export async function listLatestPosts(db: Firestore, n = 20): Promise<Post[]> {
const q = query(postsCollection(db), orderBy("createdAt", "desc"), limit(n));
const snaps = await getDocs(q);
return snaps.docs.map((d) => d.data());
}

export async function updatePostTitle(db: Firestore, postId: string, title: string): Promise<void> {
// ✅ updateDoc は Converter を使ってくれません(重要)ので、
// setDoc(merge:true) で更新するのが安全寄り
const ref = doc(postsCollection(db), postId);
await setDoc(ref, { title, updatedAt: new Date() } as any, { merge: true });
}

export async function deletePost(db: Firestore, postId: string): Promise<void> {
const ref = doc(postsCollection(db), postId);
await deleteDoc(ref);
}

超重要⚠️ updateDoc の罠に注意

firebase_firestore_struncture_ts_study_019_06_updatedoc_trap.png

公式の例でもハッキリ書かれてます👇 Converter は setDoc / addDoc / getDoc では使われるけど、updateDoc の書き込みでは使われません。(Firebase)

つまり、こういう事故が起きます😱

  • UIモデルの「Date」をそのまま updateDoc で投げる
  • 本当は DTO の「Timestamp」であるべき
  • 変なデータが混ざる
  • 次回読み込みでガードが爆発💥(でもこれは早期発見できて良い!)

なのでこの章では、更新は基本👇でいきます😊

  • 更新は setDoc + merge:true を第一候補にする✨(Firebase)

React 側での使い方イメージ⚛️✨

「型がついてる」気持ちよさを味わうところです😆

// src/features/posts/PostList.tsx
import { useEffect, useState } from "react";
import type { Firestore } from "firebase/firestore";
import type { Post } from "./types";
import { listLatestPosts } from "./repository";

export function PostList({ db }: { db: Firestore }) {
const [posts, setPosts] = useState<Post[]>([]);

useEffect(() => {
(async () => {
const data = await listLatestPosts(db, 20);
setPosts(data);
})();
}, [db]);

return (
<div>
<h2>最新記事📰</h2>
{posts.map((p) => (
<div key={p.id} style={{ borderBottom: "1px solid #ddd", padding: 8 }}>
<div style={{ fontWeight: 700 }}>{p.title}</div>
<div>by {p.authorName} 👤</div>
<div>{p.createdAt.toLocaleString()}</div>
<div>コメント {p.commentCount} 💬</div>
</div>
))}
</div>
);
}

AI でこの章を “倍速” にする🤖⚡

1) Antigravity で設計レビューを回す🧠🛠️

Antigravity は「エージェントが計画→実行→検証」を回す “Mission Control” 的な思想が公式Codelabにあります。(Google Codelabs) ここでやると強いのは👇

  • DTO と UI モデルの 差分レビュー
  • Converter の 漏れフィールド検出
  • ガードの チェック項目の提案

依頼文の例(そのまま貼れるやつ)👇📎

  • 「Post/PostDTO/Converter/Guard を見て、事故りやすい点と修正案を出して。特に updateDoc の混入リスクもチェックして」

2) Gemini CLI に雛形を作らせる🧱✨

Gemini CLI はターミナルで動くAIエージェントで、ReAct ループで作業を進める説明があります。(Google Cloud Documentation) 生成させると美味しいのは👇

  • Converter の雛形
  • omitUndefined などの小物関数
  • ガードのパターン(必須・型・範囲)

3) Firebase AI Logic と Firestore をつなぐ時の注意🧯

Firebase AI Logic はアプリ向けの仕組みで、Firestore など Firebase の他サービスとも組み合わせられることが公式に説明されています。(Firebase) ただし本番を考えるなら👇は必ず意識😄

  • デフォルトのユーザー別レート制限(例:100 RPM / user)(Firebase)
  • App Check で不正クライアントをブロック(AI Logic は App Check と連携できる)(Firebase)

この章の型安全CRUDと相性がいいのは、AIの出力を Firestore に保存するとき👇

  • 「AIログ」「要約」「タグ」みたいな 構造化データを DTO で固定して、Converter + ガードで守る🛡️✨

4) Genkit で “構造化出力” を取る発想📦✨

Genkit はスキーマを定義して、LLM から 構造化データを返させる流れが紹介されています。(Firebase) これを Firestore に保存するなら、まさに DTO/Converter/ガードの出番です😆


ミニ課題📝🔥 AI 由来のデータを型安全に保存してみよう

お題🎯

記事(Post)に「AI要約」を付けたい!✨

firebase_firestore_struncture_ts_study_019_07_structured_ai_data.png

  • UIでは「summaryText: string」だけ欲しい
  • DBには「summaryText / model / createdAt」を保存したい
  • 変な形が入ったらガードで落とす

やること✅

  1. Post に UI用フィールド(summaryText)を追加
  2. PostDTO に保存用フィールド(summaryText, summaryModel, summaryCreatedAt)を追加
  3. Converter に変換を追加
  4. ガードにチェックを追加
  5. AI Logic または Genkit で生成した summary を保存(まずはダミー文字列でもOK😄)

チェック✅✨ この章を終えたら勝ち!

  • DTO と UI モデルの “ズレ” を 意図して設計できる😄
  • Converter の fromFirestore で ガードしてる(事故が即発見できる)🛡️
  • updateDoc の罠を避けて setDoc + merge で更新できる⚠️(Firebase)
  • React 側が Firestore の都合を知らずに 型だけで気持ちよく描画できる💖
  • AI の出力も DTO に落として 保存の事故を減らせる🤖✨(Firebase)

次の第20章(整合性をサーバー側で守る)に進むと、この章で作った DTO/Converter/ガードがそのまま “堅牢な土台” になりますよ〜😄⚙️