Subcollections
Navigate Firestore document hierarchies by deriving a child repository from a parent document, with explicit (never inherited) converters and per-instance schema validation.
Accessing a subcollection
Section titled “Accessing a subcollection”Call subcollection() on any repository to get a new FirestoreRepository scoped to a
subcollection under a specific parent document. The returned repository targets the path
{parentCollection}/{parentId}/{name} and exposes the full read/write/query surface.
import { z } from 'zod';import { FirestoreRepository } from 'flintfire';
const orderSchema = z.object({ product: z.string(), price: z.number(), status: z.enum(['pending', 'completed', 'cancelled']), createdAt: z.number(),});type Order = z.infer<typeof orderSchema>;
// Access the `orders` subcollection under user `user-123`const userOrders = userRepo.subcollection('user-123', 'orders', orderSchema);
// Create an order in the subcollection (the document id is auto-generated by Firestore)const order = await userOrders.create({ product: 'Widget', price: 99.99, status: 'pending', createdAt: Date.now(),});
// Read it backconst fetched = await userOrders.getById(order.id);Signature
Section titled “Signature”subcollection mirrors
withSchema: read/write types
are inferred from schema values, and a trailing options object carries writeSchema,
storedSchema, readConverter, sentinelPolicy, and allowLegacyDatastoreIds. As with
withSchema, storedSchema is required whenever a readConverter is set.
subcollection< RS extends z.ZodObject<any>, WS extends z.ZodObject<any> = RS, SS extends z.ZodObject<any> = RS,>( parentId: ID, subcollectionName: string, readSchema: RS, options?: { writeSchema?: WS; storedSchema?: SS; readConverter?: ReadConverter<z.output<RS>>; sentinelPolicy?: SentinelPolicy; allowLegacyDatastoreIds?: boolean; },): FirestoreRepository<z.output<RS>, z.input<WS>, z.output<SS>, z.output<WS>>;The read type is z.output<readSchema>; the write-input type is z.input<writeSchema> when a
writeSchema overlay is supplied (otherwise it equals the read type). Pass a writeSchema built
from the write combinators for cast-free sentinel/combinator writes:
// A write overlay gives cast-free combinator writes (same ergonomics as `withSchema`)const orderWrite = orderSchema.extend({ price: zNumberWrite() }); // number | incrementconst userOrders = userRepo.subcollection('user-123', 'orders', orderSchema, { writeSchema: orderWrite,});await userOrders.update('o1', { price: FieldValue.increment(5) }); // no cast neededoptions.sentinelPolicy defaults to 'strict' ('permissive' is the opt-in, pre-v3 default). See
field-value sentinels for what strict mode
enforces.
No schema passed to
subcollection()may declare a top-levelid— the document name is the sole source ofid. A top-levelidin thereadSchema(or any overlay) is rejected at construction. See schema validation for the rules onidhandling. For an unvalidated subcollection, construct a repository directly against the full path:FirestoreRepository.raw<Order>(db, 'users/user-123/orders').
Querying a subcollection
Section titled “Querying a subcollection”The child repository has its own query builder, scoped to the subcollection path:
const recentOrders = await userOrders .query() .where('status', '==', 'completed') .orderBy('createdAt', 'desc') .limit(10) .get();For forward opaque paging use paginate(pageSize, cursor?) (it requires a prior orderBy()). For
typed bounds and reverse pages (startAt / endAt / limitToLast), see
queries.
Querying every parent’s subcollection at once
Section titled “Querying every parent’s subcollection at once”A subcollection repository is scoped to one parent. To read the same subcollection across every
parent — “all orders, for all users” — use collectionGroup():
const allOrders = await userOrders .collectionGroup() .query() .where('status', '==', 'completed') .get();
allOrders[0].path; // 'users/user-987/orders/o-42'allOrders[0].parentPath; // 'users/user-987/orders'Group results carry the full path because document ids are not unique across a collection
group, and the surface is read-only. See
collection-group queries
for the full contract, including the index requirement.
Nested subcollections
Section titled “Nested subcollections”Because subcollection() returns a full repository, you can chain calls to descend multiple levels:
const commentSchema = z.object({ body: z.string(),});const replySchema = z.object({ body: z.string(),});type Comment = z.infer<typeof commentSchema>;type Reply = z.infer<typeof replySchema>;
// posts/post-123/comments/comment-456/repliesconst replies = postRepo .subcollection('post-123', 'comments', commentSchema) .subcollection('comment-456', 'replies', replySchema);Converters are not inherited
Section titled “Converters are not inherited”A converter is attached per repository instance and is never inherited from the parent
repository. If a child collection needs converter behavior, pass the converter explicitly to
subcollection().
import { ReadConverter } from 'flintfire';
const userSchema = z.object({ name: z.string(),});type User = z.infer<typeof userSchema>;
// Parent and child each declare their own read converter (the `fromFirestore` mapper only).// The mapper returns data WITHOUT `id` — the repository overlays the document id afterward.const userReadConverter: ReadConverter<User> = snapshot => snapshot.data() as User;const orderReadConverter: ReadConverter<Order> = snapshot => snapshot.data() as Order;
const users = FirestoreRepository.withSchema(db, 'users', userSchema, { readConverter: userReadConverter, storedSchema: userSchema, // required with a readConverter; here the mapper doesn't reshape});
// No converter inheritance: pass the child converter explicitly.const userOrders = users.subcollection('user-123', 'orders', orderSchema, { readConverter: orderReadConverter, storedSchema: orderSchema,});See core concepts for the full converter contract
(converters are read-only — a fromFirestore mapper that runs on reads — and why it must omit
id).
Inspecting subcollection metadata
Section titled “Inspecting subcollection metadata”A subcollection repository exposes helpers for reasoning about its place in the hierarchy:
const userOrders = userRepo.subcollection('user-123', 'orders', orderSchema);
userOrders.getParentId(); // 'user-123' (null for a top-level collection)userOrders.getCollectionPath(); // 'users/user-123/orders'userOrders.isSubcollection(); // true
const topLevel = FirestoreRepository.raw<User>(db, 'users');topLevel.getParentId(); // nulltopLevel.isSubcollection(); // falseFirestoreRepository’s constructor is
new FirestoreRepository(db, collectionPath, validator?, parentPath?, readConverter?, schemas?, allowLegacyDatastoreIds?)
— there is no options/config/logging bag. In practice, prefer subcollection() and the
withSchema factory over
constructing repositories by hand.
Related
Section titled “Related”- CRUD operations — create, read, update, delete on the child repository
- Queries — filtering, aggregations, pagination, and streaming
- Schema validation — no top-level
id, derived schemas - Field-value sentinels —
sentinelPolicyand strict mode - Core concepts — repository pattern and converter behavior