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 '@reggieofarrell/firestore-orm';
const orderSchema = z.object({ id: z.string(), 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<Order>('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”There are two forms of subcollection. Pick whichever matches how you type writes.
Direct form — the schema is optional, and write inputs are typed by the read type U:
subcollection<U extends { id?: ID }>( parentId: ID, name: string, schema?: z.ZodObject<any>, converter?: FirestoreDataConverter<U>, opts?: { sentinelPolicy?: 'permissive' | 'strict' },): FirestoreRepository<U>;Curried form — fix the read type in the first (empty) call so TypeScript infers the write model
from the schema argument (W = z.infer<schema>), giving cast-free writes for sentinel/combinator
fields. The schema is required in this form:
subcollection<U extends { id?: ID }>(): <S extends z.ZodObject<any>>( parentId: ID, name: string, schema: S, converter?: FirestoreDataConverter<U>, opts?: { sentinelPolicy?: 'permissive' | 'strict' },) => FirestoreRepository<U, z.infer<S>>;// Curried: infer write-input types from the schema (same ergonomics as `withSchema`)const userOrders = userRepo.subcollection<Order>()('user-123', 'orders', orderSchema);await userOrders.update('o1', { price: FieldValue.increment(5) }); // no cast neededopts.sentinelPolicy defaults to 'permissive'. See
field-value sentinels for what strict mode
enforces.
A provided schema must include a required top-level
id: z.string(). If it does not, the repository throws at construction. See schema validation for the rules onidhandling.
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 cursor-based paging use paginate(pageSize, cursor?) (it requires a prior orderBy()); there
is no .startAfter() chaining. See queries for the full query surface.
Nested subcollections
Section titled “Nested subcollections”Because subcollection() returns a full repository, you can chain calls to descend multiple levels:
const commentSchema = z.object({ id: z.string(), body: z.string(),});const replySchema = z.object({ id: z.string(), 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<Comment>('post-123', 'comments', commentSchema) .subcollection<Reply>('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 type { FirestoreDataConverter } from 'firebase-admin/firestore';
const userSchema = z.object({ id: z.string(), name: z.string(),});type User = z.infer<typeof userSchema>;
// Parent and child each declare their own converter.// `fromFirestore` returns data WITHOUT `id` — the repository overlays the document id afterward.const userConverter: FirestoreDataConverter<User> = { toFirestore: data => data, fromFirestore: snapshot => snapshot.data() as User,};const orderConverter: FirestoreDataConverter<Order> = { toFirestore: data => data, fromFirestore: snapshot => snapshot.data() as Order,};
const users = FirestoreRepository.withSchema<User>(db, 'users', userSchema, userConverter);
// No converter inheritance: pass the child converter explicitly.const userOrders = users.subcollection<Order>('user-123', 'orders', orderSchema, orderConverter);See core concepts for the full converter contract (when toFirestore and
fromFirestore run, and why fromFirestore 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<Order>('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 = new FirestoreRepository<User>(db, 'users');topLevel.getParentId(); // nulltopLevel.isSubcollection(); // falseFirestoreRepository’s constructor is
new FirestoreRepository(db, collectionPath, validator?, parentPath?, converter?, schemas?) — 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 — required
id, derived schemas - Field-value sentinels —
sentinelPolicyand strict mode - Core concepts — repository pattern and converter behavior