zod firebase-admin schema
npm install zod-firebase-admin



Type-safe Firestore collections and documents using Zod schemas for the Firebase Admin SDK.
Peer dependencies: firebase-admin and zod.
``bash`
npm install zod-firebase-admin zod firebase-admin
Node.js >= 22 is recommended (matches the library engines field). ESM and CJS bundles are provided.
First, define your document schemas using Zod:
`typescript
import { z } from 'zod'
import { collectionsBuilder } from 'zod-firebase-admin'
// Define your document schemas
const UserSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number().optional(),
tags: z.array(z.string()).optional().default([]),
})
const PostSchema = z.object({
title: z.string(),
content: z.string(),
authorId: z.string(),
publishedAt: z.date(),
likes: z.number().default(0),
})
// Define your collection schema
const schema = {
users: {
zod: UserSchema,
},
posts: {
zod: PostSchema,
},
} as const
// Build type-safe collections
const collections = collectionsBuilder(schema)
`
`typescript
// Create a new user
const userRef = await collections.users.add({
name: 'John Doe',
email: 'john@example.com',
age: 30,
})
// Get a user by ID
const user = await collections.users.findByIdOrThrow(userRef.id)
console.log(user._id, user.name, user.email) // Fully typed!
// Update a user
await collections.users.update(userRef.id, {
age: 31,
})
// Query users
const adults = await collections.users.findMany({
name: 'adults',
where: [['age', '>=', 18]],
})
// Delete a user
await collections.users.delete(userRef.id)
`
Return a document if it exists or a validated fallback when it does not.
`typescript
// Multi-document collection: findByIdWithFallback(id, fallback)
const post = await collections.posts.findByIdWithFallback('post123', {
title: 'Untitled',
content: '',
authorId: 'anonymous',
publishedAt: new Date(),
likes: 0,
})
// If the document exists, you get its data; otherwise you get:
// { _id: 'post123', title: 'Untitled', content: '', ... }
// Single-document collection: findWithFallback(fallback)
const userId = 'user123'
const profile = await collections.users(userId).profile.findWithFallback({
bio: 'This user has not set up a bio yet',
avatar: undefined,
})
// If the document does not exist, you get { _id: 'profile', ...fallback }
`
When your schema validates document IDs (includeDocumentIdForZod), the fallback is validated
with the injected _id:
`typescript
const UserWithIdSchema = z.discriminatedUnion('_id', [
z.object({
_id: z.literal('admin'),
name: z.string(),
role: z.literal('administrator'),
}),
z.object({
_id: z.string(),
name: z.string(),
role: z.literal('user'),
}),
])
const schema = {
users: {
zod: UserWithIdSchema,
includeDocumentIdForZod: true,
},
} as const
const collections = collectionsBuilder(schema)
// Fallback excludes _id; it will be injected and validated by Zod
const admin = await collections.users.findByIdWithFallback('admin', {
name: 'System',
role: 'administrator',
})
`
You can define nested sub-collections with full type safety:
`typescript
const schema = {
users: {
zod: UserSchema,
posts: {
zod: PostSchema,
// Sub-sub-collections
comments: {
zod: z.object({
text: z.string(),
authorId: z.string(),
createdAt: z.date(),
}),
},
},
// Single document sub-collection
profile: {
zod: z.object({
bio: z.string(),
avatar: z.string().optional(),
}),
singleDocumentKey: 'profile', // Fixed document ID
},
},
} as const
const collections = collectionsBuilder(schema)
// Working with sub-collections
const userId = 'user123'
// Add a post to user's posts sub-collection
const postRef = await collections.users(userId).posts.add({
title: 'My First Post',
content: 'Hello world!',
authorId: userId,
publishedAt: new Date(),
})
// Add a comment to the post
await collections.users(userId).posts(postRef.id).comments.add({
text: 'Great post!',
authorId: 'commenter123',
createdAt: new Date(),
})
// Work with single document sub-collection
await collections.users(userId).profile.set({
bio: 'Software developer',
avatar: 'https://example.com/avatar.jpg',
})
const profile = await collections.users(userId).profile.findOrThrow()
`
Query across all sub-collections of the same type:
`typescript
// Count all posts across all users
const postCount = await collections.users.posts.group.count({
name: 'all-posts',
})
// Count all comments across all posts
const commentCount = await collections.users.posts.comments.group.count({
name: 'all-comments',
})
`
#### Custom Error Handling
`typescriptValidation error for document ${snapshot.id}:
const collections = collectionsBuilder(schema, {
zodErrorHandler: (error, snapshot) => {
console.error(, error)Invalid document: ${snapshot.id}
return new Error()`
},
})
#### Data Transformation
Handle Firebase-specific data types like Timestamps:
`typescript
import { Timestamp } from 'firebase-admin/firestore'
const collections = collectionsBuilder(schema, {
snapshotDataConverter: (snapshot) => {
const data = snapshot.data()
// Convert Firestore Timestamps to JavaScript Dates
return Object.fromEntries(
Object.entries(data).map(([key, value]) => [key, value instanceof Timestamp ? value.toDate() : value]),
)
},
})
`
#### Document ID Validation
Include document IDs in Zod validation:
`typescript
const UserWithIdSchema = z.discriminatedUnion('_id', [
z.object({
_id: z.literal('admin'),
name: z.string(),
role: z.literal('administrator'),
}),
z.object({
_id: z.string(),
name: z.string(),
role: z.literal('user'),
}),
])
const schema = {
users: {
zod: UserWithIdSchema,
includeDocumentIdForZod: true,
},
} as const
`
#### Read-Only Documents
Mark collections as read-only to prevent accidental modifications:
`typescript
const schema = {
config: {
zod: ConfigSchema,
readonlyDocuments: true,
},
} as const
// This collection will only have read operations available
const collections = collectionsBuilder(schema)
`
Work with Firebase Admin transactions and batch writes:
`typescript
import { getFirestore } from 'firebase-admin/firestore'
const db = getFirestore()
// Using transactions
await db.runTransaction(async (transaction) => {
const userSnap = await transaction.get(collections.users.read.doc('user123'))
if (!userSnap.exists) return
transaction.update(collections.users.write.doc('user123'), { age: FieldValue.increment(1) })
})
// Using batch writes
const batch = db.batch()
batch.create(collections.posts.write.doc('post123'), {
title: 'Batch Post',
authorId: 'user123',
})
await batch.commit()
`
Take advantage of Firebase Admin SDK server-side capabilities:
`typescript
// Precondition checks with last update time
const user = await collections.users.findByIdOrThrow('user123', {
_updateTime: true,
})
await collections.users.update(
'user123',
{
name: 'Updated Name',
},
{
lastUpdateTime: user._updateTime, // Prevents concurrent modifications
},
)
// Server timestamps and field values
import { FieldValue } from 'firebase-admin/firestore'
await collections.users.update('user123', {
lastLoginAt: FieldValue.serverTimestamp(),
loginCount: FieldValue.increment(1),
})
`
#### Advanced Queries
`typescript
// Complex queries with multiple conditions
const recentPopularPosts = await collections.posts.findMany({
where: [
['publishedAt', '>=', new Date('2024-01-01')],
['likes', '>=', 100],
],
orderBy: [['likes', 'desc']],
limit: 10,
})
// Prepared queries for reuse
const popularPrepared = collections.posts.prepare({
name: 'popular',
where: [['likes', '>=', 100]],
orderBy: [['likes', 'desc']],
})
const snapshot = await popularPrepared.get()
const results = snapshot.docs.map((d) => d.data())
`
#### QuerySpecification
The QuerySpecification accepted by prepare, query, find*, and count supports:
- name: A label for your query, used in error messages
- where?: Either an array of tuples [field, op, value] or an Admin Filter[field]
- Tuples: Simple queries
- Filters: Admin Filter API
- orderBy?: Array of tuples or [field, 'asc' | 'desc']orderBy
- Docs: Order and limit data
- limit?: Maximum number of results
- Docs: Order and limit data
- limitToLast?: Returns the last N results; requires a matching startAt(...values)
- Docs: Order and limit data
- offset?: Skip the first N results
- Docs: Pagination with offset
- startAt? | startAfter? | endAt? | endBefore?: Cursor boundaries, each can be a document snapshot or an array of field values
- Arrays are forwarded as individual arguments (e.g. ). Ensure the order matches your orderBy
- Docs: Query cursors
Related:
- Collection group queries: Guide
- Aggregations via Admin SDK count(): Admin Query.count
#### Metadata Access
Access Firestore metadata when needed:
`typescript
// Get document with metadata
const userWithMeta = await collections.users.findByIdOrThrow(userId, {
_createTime: true,
_updateTime: true,
})
console.log(userWithMeta._createTime, userWithMeta._updateTime)
`
- DocumentInput: Input type inferred with z.input from your Zod schema Z (data you write). See Zod’s input/output docsDocumentOutput
- : Output type inferred with z.output from your Zod schema Z, plus optional metadata based on Options:_id
- (string) included by default unless { _id: false }_createTime
- / _updateTime available via operation options when reading, _metadata for web parity is not used here{ readonly: true }
- When , the data portion is deeply readonlySchemaDocumentInput
- : Input type for a collection built from a Zod schema; accepts either the input type or a deeply readonly version of itSchemaDocumentOutput
- : Output type for a collection built from a Zod schema, mirroring DocumentOutput behavior and honoring readonlyDocuments in the collection schema
Examples:
`ts
import { z } from 'zod'
const User = z.object({
name: z.string(),
admin: z.boolean().default(false),
})
type UserInput = DocumentInput
type UserOutput = DocumentOutput
type ReadonlyUserOutput = DocumentOutput
// With collectionsBuilder
const schema = {
users: { zod: User },
} as const
type UsersInput = SchemaDocumentInput
// { name: string; admin?: boolean } | ReadonlyDeep<{ name: string; admin?: boolean }>
type UsersOutput = SchemaDocumentOutput
// { _id: string; name: string; admin: boolean }
// Include Admin metadata in outputs
type UsersOutputWithTimes = SchemaDocumentOutput
// { _id: string; _createTime: Timestamp; _updateTime: Timestamp; name: string; admin: boolean }
// Readonly collection
const readonlySchema = {
users: { zod: User, readonlyDocuments: true },
} as const
type ReadonlyUsersOutput = SchemaDocumentOutput
// ReadonlyDeep<{ name: string; admin: boolean }> & { _id: string }
`
- add(data) - Add a new document with auto-generated IDcreate(id, data)
- - Create a document with specific IDset(id, data, options?)
- - Set document data (overwrites)update(id, data, options?)
- - Update document fieldsdelete(id, options?)
- - Delete a documentfindById(id, options?)
- - Find document by ID (returns undefined if not found)findByIdOrThrow(id, options?)
- - Find document by ID (throws if not found)findMany(query)
- - Query multiple documentscount(query)
- - Count documents matching queryprepare(query)
- - Prepare a query for reuse
- collection(parentId).subCollection - Access sub-collectioncollection.subCollection.group
- - Access collection group
- zodErrorHandler - Custom error handling for validation failuressnapshotDataConverter
- - Transform document data before validationincludeDocumentIdForZod
- - Include document ID in Zod validationreadonlyDocuments
- - Mark collection as read-onlysingleDocumentKey
- - Create single-document sub-collections
- transaction - Run operation within a transactionbatch
- - Add operation to a batch writelastUpdateTime
- - Precondition check for updates_createTime
- / _updateTime - Include metadata in results
This package is designed for server-side applications using the Firebase Admin SDK. Key differences from the web SDK version (zod-firebase):
- Server Environment: Runs in Node.js with admin privileges
- No Authentication: Admin SDK bypasses Firebase Auth
- Transactions: Full transaction support with preconditions
- Batch Operations: Atomic batch writes
- Server Timestamps: Access to server-side timestamp operations
- Metadata Access: Read/write times and other document metadata
For client-side applications, use zod-firebase` instead.
MIT
See the main repository at valian-ca/zod-firebase-admin for contributing guidelines.