Document Identity
In v3 the Firestore document name is the sole authority for id. Your schemas describe the
document’s own data and never declare an id; the repository overlays the id onto every read. This
page is the canonical reference for the identity model — the other guides link here rather than
restating it.
The no-top-level-id rule
Section titled “The no-top-level-id rule”A read / write / stored schema must not declare a top-level id field. withSchema(...) (and
subcollection(...)) throws at construction with a remedial error if one is present — the document
name is the only source of id, so a schema-level id would be ambiguous and is disallowed.
// ❌ throws at construction — remove the id fieldconst bad = z.object({ id: z.string(), name: z.string() });FirestoreRepository.withSchema(db, 'users', bad);
// ✅ the schema describes data only; the repository owns identityconst userSchema = z.object({ name: z.string(), email: z.email() });const userRepo = FirestoreRepository.withSchema(db, 'users', userSchema);A nested field named id (e.g. author.id) is unaffected — only a top-level id is rejected.
Where id lives
Section titled “Where id lives”The read type T carries no id. Reads resolve to FirestoreDocument<T> — a distributive
conditional for unresolved generics so union read models narrow on discriminant checks (ADR-0028).
For a concrete T the shape is:
type FirestoreDocument<T> = Omit<T, 'id'> & { readonly id: ID };The id is always taken from snapshot.id (the document name) and overlaid after the read — it is
readonly, and never part of a write payload. So there are three distinct places identity shows up:
- Schemas / write payloads — no
id. It is generated oncreate, or supplied as theidargument toupdate/patch/upsert/delete. - Read results — a flat
FirestoreDocument<T>withidoverlaid from the document name. - Queries — the synthetic
idis not a stored field path; query the document name withwhereId(...)/orderById(...)(see below).
ID is a string alias. DataOf<R> / DocumentOf<R> extract a repository’s read-data and
document types without spelling the generics — see
Exported Types.
Generating ids
Section titled “Generating ids”create(data)andcreateInTransaction(tx, data)auto-generate a fresh id and return{ id }(or the document with{ returnDoc: true }).repo.newId(): IDgenerates a validated auto-id without writing. Persist under it explicitly withupsert(id, …)or a transactionset.upsert(id, data)creates or overwrites the document at a caller-chosen id — the path for deterministic/static ids (see ID Strategies).
Validating untrusted ids at the boundary
Section titled “Validating untrusted ids at the boundary”Every id-taking surface validates its id before touching Firestore — getById, update, patch,
upsert, delete, the bulk* methods, their *InTransaction equivalents, whereId, and
whereFilter’s f.whereId. A malformed id throws InvalidDocumentIdError rather than escaping the
collection boundary.
An id is rejected when it is not a string, empty, contains /, is . or .., is wrapped in a
__…__ reserved pattern, exceeds 1500 UTF-8 bytes, or contains invalid UTF-16 (a lone surrogate).
Each case maps to a distinct InvalidDocumentIdReason: not_string, empty, contains_slash,
reserved_dot_segment, reserved_namespace, too_long, invalid_utf8. A dot inside a name is
fine — only the exact values . and .. are rejected. Validate a request-supplied id explicitly with
repo.id(raw) before use — it returns the value as an ID or throws:
// A route handler receiving an untrusted idapp.get('/users/:id', async (req, res, next) => { try { const user = await userRepo.getById(userRepo.id(req.params.id)); res.json(user); } catch (err) { next(err); // InvalidDocumentIdError → 400 via the Express errorHandler }});InvalidDocumentIdError carries a machine-readable reason (InvalidDocumentIdReason) and maps to
a 400 in the
Express middleware. The
error class is documented under
Error Handling, and the security
rationale under Trust Boundary & Security.
Querying by id
Section titled “Querying by id”The synthetic id is not a stored field path, so where('id', …) and orderBy('id') are
compile errors. Query the document name with the id-aware clauses instead:
// ❌ compile error — id is not a stored fieldrepo.query().where('id', '==', 'user-1');
// ✅ native document-name query via FieldPath.documentId()await repo.query().whereId('==', 'user-1').getOne();await repo.query().whereId('in', ['user-1', 'user-2']).get();
// stable pagination tiebreakerawait repo.query().orderBy('createdAt', 'desc').orderById().paginate(20);whereId(op, value) takes a string for scalar operators and a readonly string[] for in /
not-in; orderById(direction?) defaults to ascending. See
Queries and
FirestoreQueryBuilder.
Identity across a collection group
Section titled “Identity across a collection group”An id is unique only within one collection. A
collection-group query
spans every collection with the same id at any depth, so users/u1/posts/p1 and users/u2/posts/p1
are two different documents that both report id: 'p1'.
Group reads therefore return a CollectionGroupDocument<T> — the same flat shape, but with the full
document path and the containing parentPath overlaid alongside id:
const rows = await postRepo.collectionGroup().query().where('status', '==', 'draft').get();
rows[0].id; // 'p1' ← ambiguous across the grouprows[0].path; // 'users/u2/posts/p1' ← the identity that is uniquerows[0].parentPath; // 'users/u2/posts'Key group results by path, never by id. The same overlay rule as id applies: identity is
written on top of the document data, so a field named path or parentPath would be shadowed.
collectionGroup() throws if a schema-validated repository declares either at the top level of its
read or stored schema — the stored model matters too, because query field paths derive from it,
so where('path', …) would filter a field the result can never expose. An unvalidated (raw)
repository has no schema to inspect; there the Omit in the result type is the only signal.
Document-name queries on a group take the full path too — wherePath(...) / orderByPath(...)
replace whereId(...) / orderById(...), and their operands clear the same validation boundary,
segment by segment.
Legacy Datastore ids
Section titled “Legacy Datastore ids”Datastore-mode databases can have integer document ids, which do not satisfy the string-id rules
above. Opt into accepting them per repository with allowLegacyDatastoreIds: true in the
withSchema / subcollection options bag. Leave it off (the default) for Native-mode Firestore.