Data Modeling

Personal Catalogue: React & Firebase

Chapter 2 · Data Modeling

Chapter 1 promised two things for this chapter: the real Firestore document shapes, built to sit side by side with the MongoDB sibling's own schema for direct comparison, and a real answer to the validation gap that comparison exposed. This chapter delivers both — though not every piece of the second one lands here; where the resulting validation actually gets called from is still an open architectural question, resolved for real in Chapter 3.

What the MongoDB Sibling Built

Personal Catalogue (React, Express & MongoDB)'s own Chapter 2 built a base Item schema (title, releaseYear, notes) and four Mongoose discriminators — Book, Cd, Dvd, Bluray — each adding real, type-specific fields on top of that shared base, all writing into one physical items collection distinguished by a stored itemType field. Every discriminator schema still enforces its own required fields at write time: a Cd document with no artist is rejected by Mongoose before MongoDB ever sees it.

This chapter builds the same real shapes — the same field names, the same required fields, even the same itemType field name — in Firestore. What Firestore can't reuse is the enforcement mechanism itself: there is no Firestore equivalent of Item.discriminator(). A Firestore collection has no declared shape at all, for any document, ever.

The Same Shapes, No Enforcement

Firestore's own flat document model actually makes reproducing the shapes easy — a document can hold any fields, so the base fields and a given item type's own fields just sit together directly on one document, with no separate base-versus-subtype schema objects needed at all:

// addItems.js import { collection, addDoc } from "firebase/firestore"; import { db } from "./firebase"; const itemsRef = collection(db, "items"); // A Book document await addDoc(itemsRef, { itemType: "Book", title: "FastAPI: Modern Python Web Development", releaseYear: 2023, notes: "", author: "Bill Lubanovic", isbn: "978-1098135508", publisher: "O'Reilly Media", tags: ["Python", "Web Development", "Web Frameworks"], }); // A Cd document, same collection await addDoc(itemsRef, { itemType: "Cd", title: "The Suburbs", releaseYear: 2010, artist: "Arcade Fire", tracklist: ["The Suburbs", "Ready to Start", "Modern Man"], });

Both documents land in the same items collection, side by side, exactly as the MongoDB sibling's own discriminators arrange things — but nothing about that addDoc call checked that a Cd document actually needs an artist, or that itemType is even one of the four real values this app understands. A typo — itemType: "CD", capitalized differently from every other document — would be accepted by Firestore exactly as readily as a correctly-typed one.

items collection (one Firestore collection, four document shapes) ├── { itemType: "Book" } title, releaseYear, notes, author, isbn, publisher, tags[] ├── { itemType: "Cd" } title, releaseYear, notes, artist, tracklist[] ├── { itemType: "Dvd" } title, releaseYear, notes, director, runtimeMinutes, regionCode └── { itemType: "Bluray" } title, releaseYear, notes, director, runtimeMinutes, resolution

Field for field, this is the identical shape the MongoDB sibling settled on. The diagram above could be relabeled "MongoDB" and nothing in it would need to change — the real divergence between the two courses isn't in what gets stored, it's in what happens the instant before storage.

Closing the Validation Gap: a Real Shape Validator

Chapter 1 left this open rather than asserting an answer. Here's the answer, at least at the "what should be checked" layer: a small, standalone function that knows this app's own real rules — which fields each item type requires, and which fields have a restricted set of valid values, the direct equivalent of Mongoose's own required: true and enum options:

// validateItem.js const REQUIRED_BY_TYPE = { Book: ['author'], Cd: ['artist'], Dvd: [], Bluray: [], }; const VALID_ITEM_TYPES = Object.keys(REQUIRED_BY_TYPE); export function validateItem(data) { if (!data.title) { throw new Error('Every item needs a title.'); } if (!VALID_ITEM_TYPES.includes(data.itemType)) { throw new Error(`itemType must be one of ${VALID_ITEM_TYPES.join(', ')}, got "${data.itemType}".`); } for (const field of REQUIRED_BY_TYPE[data.itemType]) { if (!data[field]) { throw new Error(`A ${data.itemType} needs a ${field}.`); } } if (data.itemType === 'Bluray' && data.resolution && !['1080p', '4K'].includes(data.resolution)) { throw new Error(`resolution must be "1080p" or "4K", got "${data.resolution}".`); } }

Run against the two documents above, plus two deliberately broken ones:

validateItem({ itemType: 'Book', title: 'x', author: 'y' }); // passes, no error validateItem({ itemType: 'Cd', title: 'x' }); // throws: "A Cd needs a artist." validateItem({ itemType: 'CD', title: 'x', artist: 'y' }); // throws: "itemType must be one of Book, Cd, Dvd, Bluray, got \"CD\"." validateItem({ itemType: 'Bluray', title: 'x', resolution: '720p' }); // throws: resolution error

That's the exact same class of rejection the MongoDB sibling's own discriminators give for free — a Cd with no artist never reaches storage — reproduced by hand in plain JavaScript, because Firestore itself will never reject it on its own.

This Function Doesn't Care Where It's Called From
validateItem() is deliberately written with no dependency on React, Express, or Firestore itself — it just takes a plain object and throws or doesn't. That's on purpose: whether it ends up running in the browser immediately before an addDoc call, inside a small backend route, or both, is exactly the client-SDK-vs-backend-API question Chapter 1 raised and Chapter 3 resolves. Writing the check itself as a standalone function now means that decision doesn't force this code to be rewritten later.
A Function Call Is Not the Same Guarantee as a Rejected Write
Calling validateItem() before every addDoc() in this app's own code is a real safeguard — but it's a safeguard that lives in application code, not in Firestore itself. Anyone with direct write access to this Firestore project (via the console, a script, or a different app entirely) can still write a malformed document straight past this function without ever calling it. Firestore Security Rules, covered as part of Chapter 3's own architecture decision, are the one mechanism that can make a check like this hold regardless of what code is doing the writing.

One Real Advantage Firestore Gets for Free

The MongoDB sibling's own Chapter 2 flagged a genuine limitation worth revisiting here: a document fetched through the base Item model doesn't expose author, artist, or any other discriminator-specific field, even though the field is really there in the underlying MongoDB document — Mongoose only attaches those extra fields when a document is loaded through its own matching discriminator model.

Firestore has no equivalent problem, for a simple reason: it has no separate base-model and subtype-model objects at all. Every document fetched from the items collection comes back as a plain object carrying every field it was actually written with:

import { collection, getDocs } from "firebase/firestore"; const snapshot = await getDocs(collection(db, "items")); const everything = snapshot.docs.map((d) => ({ id: d.id, ...d.data() })); // everything[0] is a real Book object with author/isbn/publisher/tags already present — // no separate "load through the Book model" step needed to see them.
One Free Win, One Real Cost
Firestore's own lack of a model layer means there's no "base model can't see subtype fields" trap to fall into — every fetched document is simply complete. The real cost sits on the other side of the same coin: the MongoDB sibling's own Book.find() automatically filters to just Book documents because it's a genuinely separate model tied to that discriminator. A Firestore query against items has no such automatic filtering at all — finding just the Book documents needs an explicit where("itemType", "==", "Book") clause every single time, covered properly once Chapter 6 builds real search.
QuestionMongoDB sibling (Mongoose discriminators)This course (Firestore)
Base fieldstitle, releaseYear, notesSame three fields, same names
Type markeritemType (the discriminatorKey)itemType (a plain field, same name, deliberately)
Required-field enforcementBuilt in, via required: true on each discriminator schemaNot built in — a real, hand-written validateItem() function
Restricted values (e.g. Bluray resolution)Built in, via enum: [...]Not built in — checked inside the same validator
Fetching a mixed list of every item typeOne query, via the base Item modelOne query, against the plain items collection
Do subtype fields show up on a generic fetch?No — only via the matching discriminator modelYes — every document is already complete
Fetching only one item typeAutomatic — query through that discriminator modelManual — an explicit where("itemType", "==", ...)

Hands-On Exercises

Exercise 1

Write real Firestore documents for all four item types (Book, Cd, Dvd, Bluray) into the same items collection using addDoc, then write a single getDocs query that returns all four back out, confirming every subtype field is present on each returned object with no extra step.

📄 View solution
Exercise 2

Extend validateItem() to also require a director field on Dvd and Bluray documents, then run it against one valid and one invalid document of each new type to confirm the new rule actually fires.

📄 View solution
Exercise 3

Explain, in your own words, why calling validateItem() from application code is a real safeguard but not the same guarantee Mongoose discriminators give — and name the one Firestore-native mechanism that could close that remaining gap.

📄 View solution

Chapter 2 Quick Reference

  • Same shapes as the MongoDB sibling — title/releaseYear/notes plus per-type fields, one shared items collection, an itemType field distinguishing document shape
  • No built-in enforcement — Firestore accepts any document shape in any collection with no complaint
  • The gap-closer — a standalone validateItem() function reproducing Mongoose's own required/enum checks by hand
  • Still open — where validateItem() actually gets called from (client-side before a write, or a backend route) is Chapter 3's own question
  • A free win — every fetched document already carries all of its own fields; no base-model/subtype-model split to trip over
  • A real cost — filtering to one item type needs an explicit where() clause every time, unlike Mongoose's automatic per-discriminator filtering
  • Next chapter: Firestore Reads & Writes From React — the Client SDK vs. a Backend API