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

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 | increment
const userOrders = userRepo.subcollection('user-123', 'orders', orderSchema, {
writeSchema: orderWrite,
});
await userOrders.update('o1', { price: FieldValue.increment(5) }); // no cast needed

options.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-level id — the document name is the sole source of id. A top-level id in the readSchema (or any overlay) is rejected at construction. See schema validation for the rules on id handling. For an unvalidated subcollection, construct a repository directly against the full path: FirestoreRepository.raw<Order>(db, 'users/user-123/orders').

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.

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/replies
const replies = postRepo
.subcollection('post-123', 'comments', commentSchema)
.subcollection('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 { 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).

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

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