zod-firebase
v2.1.2
Published
zod firebase schema
Readme
zod-firebase
Type-safe Firestore collections and documents using Zod schemas for the Firebase Web SDK.
Installation
Peer dependencies: firebase and zod.
npm install zod-firebase zod firebaseUsage
Basic Setup
First, define your document schemas using Zod:
import { z } from 'zod'
import { collectionsBuilder } from 'zod-firebase'
// 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)CRUD Operations
// Create a new user
const userRef = await collections.users.add({
name: 'John Doe',
email: '[email protected]',
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)Fallback reads
Return a document if it exists or a validated fallback when it does not.
// 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:
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',
})Sub-Collections
You can define nested sub-collections with full type safety:
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()Collection Group Queries
Query across all sub-collections of the same type:
// Find all posts across all users
const allPosts = await collections.users.posts.group.findMany({
name: 'posts-since-2024',
where: [['publishedAt', '>=', new Date('2024-01-01')]],
})
// Aggregate with the Firestore Web SDK (e.g. count)
import { count } from 'firebase/firestore'
const totals = await collections.users.posts.comments.group.aggregateFromServer(
{ name: 'all-comments' },
{ total: count() },
)
console.log(totals.total)Advanced Options
Custom Error Handling
const collections = collectionsBuilder(schema, {
zodErrorHandler: (error, snapshot) => {
console.error(`Validation error for document ${snapshot.id}:`, error)
return new Error(`Invalid document: ${snapshot.id}`)
},
})Data Transformation
Handle Firebase-specific data types like Timestamps:
import { Timestamp } from 'firebase/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:
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 constRead-Only Documents
Mark collections as read-only to prevent accidental modifications:
const schema = {
config: {
zod: ConfigSchema,
readonlyDocuments: true,
},
} as const
// This collection will only have read operations available
const collections = collectionsBuilder(schema)Query Features
Advanced Queries
// Complex queries with multiple conditions
const recentPopularPosts = await collections.posts.findMany({
name: 'recent-popular',
where: [
['publishedAt', '>=', new Date('2024-01-01')],
['likes', '>=', 100],
],
orderBy: [['likes', 'desc']],
limit: 10,
})
// Prepare once, execute with the Web SDK
import { getDocs } from 'firebase/firestore'
const popularPrepared = collections.posts.prepare({
name: 'popular',
where: [['likes', '>=', 100]],
orderBy: [['likes', 'desc']],
})
const snapshot = await getDocs(popularPrepared)
const results = snapshot.docs.map((d) => d.data())QuerySpecification
The QuerySpecification accepted by prepare, query, and find* supports the following parameters:
- name: A label for your query, used in error messages
- constraints?: Prebuilt Web SDK
QueryConstraint[]passed directly toquery(...)- Docs: Queries guide
- where?: Array of tuples
[field, op, value]- Docs: Simple queries
- orderBy?: Array of tuples
[field]or[field, 'asc' | 'desc']- Docs: Order and limit data
- limit?: Maximum number of results
- Docs: Order and limit data
- limitToLast?: Returns the last N results; requires a matching
orderBy- Docs: Order and limit data
- 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.
startAt(...values)). Ensure the order matches yourorderBy - Docs: Query cursors
- Arrays are forwarded as individual arguments (e.g.
Related:
Metadata Access
Access Firestore metadata when needed:
// Get document with metadata
const userWithMeta = await collections.users.findByIdOrThrow(userId, {
_metadata: true,
})
console.log(userWithMeta._metadata?.hasPendingWrites)API Reference
Types
DocumentInput<Z>: Input type inferred withz.input<Z>from your Zod schemaZ(data you write). See Zod’s input/output docsDocumentOutput<Z, Options>: Output type inferred from your Zod schemaZusingz.output<Z>, optionally augmented with metadata depending onOptions:_id(string) included by default unless{ _id: false }_metadata(FirestoreSnapshotMetadata) when{ _metadata: true }- When
{ readonly: true }, the data portion is deeply readonly
SchemaDocumentInput<TCollectionSchema>: Input type for a collection built from a Zod schema; accepts either the input type or a deeply readonly version of itSchemaDocumentOutput<TCollectionSchema, Options>: Output type for a collection built from a Zod schema, mirroringDocumentOutputbehavior and honoringreadonlyDocumentsin the collection schema
Examples:
import { z } from 'zod'
const User = z.object({
name: z.string(),
age: z.number().optional(),
})
type UserInput = DocumentInput<typeof User> // { name: string; age?: number }
type UserOutput = DocumentOutput<typeof User> // { _id: string; name: string; age?: number }
type UserOutputWithMeta = DocumentOutput<typeof User, { _metadata: true }>
// { _id: string; _metadata: SnapshotMetadata; name: string; age?: number }
// With collectionsBuilder
const schema = {
users: { zod: User },
} as const
type UsersInput = SchemaDocumentInput<typeof schema.users>
// { name: string; age?: number } | ReadonlyDeep<{ name: string; age?: number }>
type UsersOutput = SchemaDocumentOutput<typeof schema.users>
// { _id: string; name: string; age?: number }
// If the collection is marked as readonly:
const readonlySchema = {
users: { zod: User, readonlyDocuments: true },
} as const
type ReadonlyUsersOutput = SchemaDocumentOutput<typeof readonlySchema.users>
// ReadonlyDeep<{ name: string; age?: number }> & { _id: string }Collection Methods
add(data)- Add a new documentset(id, data, options?)- Set document data (create or overwrite)update(id, data, options?)- Update document fieldsdelete(id)- 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)prepare(query)- Prepare a typed query for reusequery(query)- Execute a query and return a QuerySnapshotfindMany(query)- Query multiple documents and return data[]aggregateFromServer(query, aggregateSpec)- Server-side aggregates (e.g. count)
Sub-Collection Access
collection(parentId).subCollection- Access sub-collectioncollection.subCollection.group- Access collection group
Configuration Options
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
License
MIT
Contributing
See the main repository at valian-ca/zod-firebase-admin for contributing guidelines.
