Skip to content

Subcollections

Navigate Firestore document hierarchies by deriving a child repository from a parent document, with explicit (never inherited) converters and per-instance schema validation.

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 back
const fetched = await userOrders.getById(order.id);

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 needed

opts.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 on id handling.

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.

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/replies
const replies = postRepo
.subcollection<Comment>('post-123', 'comments', commentSchema)
.subcollection<Reply>('comment-456', 'replies', replySchema);

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).

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(); // null
topLevel.isSubcollection(); // false

FirestoreRepository’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.