📋 Schema Definition
Firetype uses Zod schemas to define your Firestore collection and document structures. Organize your
schemas in a directory structure that mirrors your Firestore database hierarchy.
Directory Structure
schemas/
└── database/
├── users/
│ ├── schema.ts # /users collection
│ └── posts/
│ └── schema.ts # /users/{userId}/posts subcollection
├── posts/
│ └── schema.ts # /posts collection
└── comments/
└── schema.ts # /comments collection
Basic Collection Schema
Each schema file must export a schema constant using Zod:
// schemas/database/users/schema.ts
import { z } from 'zod';
export const schema = z.object({
// Required fields
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email format'),
// Optional fields
age: z.number().int().positive().optional(),
bio: z.string().max(500).optional(),
// Nested objects
metadata: z.object({
lastLogin: z.date().optional(),
isVerified: z.boolean().default(false),
role: z.enum(['user', 'admin', 'moderator']).default('user'),
}),
// Arrays
interests: z.array(z.string()).default([]),
// Complex types
preferences: z.record(z.union([z.string(), z.number(), z.boolean()])).optional(),
// Timestamps
createdAt: z.date(),
updatedAt: z.date(),
});
Subcollection Schema
Define subcollections by creating nested directories:
// schemas/database/users/posts/schema.ts
import { z } from 'zod';
export const schema = z.object({
title: z.string().min(1, 'Title is required'),
content: z.string().min(1, 'Content is required'),
excerpt: z.string().max(200).optional(),
published: z.boolean().default(false),
publishedAt: z.date().optional(),
tags: z.array(z.string()).default([]),
likes: z.number().int().default(0),
authorId: z.string(), // Reference to parent document
createdAt: z.date(),
updatedAt: z.date(),
});
Advanced Schema Patterns
Union Types (Polymorphism)
// schemas/database/notifications/schema.ts
import { z } from 'zod';
const baseNotification = z.object({
id: z.string(),
userId: z.string(),
read: z.boolean().default(false),
createdAt: z.date(),
});
export const schema = z.discriminatedUnion('type', [
baseNotification.extend({
type: z.literal('friend_request'),
fromUserId: z.string(),
message: z.string(),
}),
baseNotification.extend({
type: z.literal('post_like'),
postId: z.string(),
likerId: z.string(),
}),
baseNotification.extend({
type: z.literal('comment_reply'),
commentId: z.string(),
postId: z.string(),
replierId: z.string(),
}),
]);
Firestore References
Firetype supports strongly-typed Firestore document references using the firestoreRef helper:
// schemas/database/posts/schema.ts
import { z } from 'zod';
import { firestoreRef, collectionPath } from '@anonymous-dev/firetype';
export const schema = z.object({
title: z.string().min(1, 'Title is required'),
content: z.string().min(1, 'Content is required'),
authorId: z.string(),
// Single reference to a user document
authorRef: firestoreRef('users'),
// Array of references to user documents
collaboratorRefs: firestoreRef('users').array(),
// Reference to a different collection
categoryRef: firestoreRef('categories'),
// Reference to subcollections
commentRef: firestoreRef('users/comments'),
// Using branded collection paths for better type safety
centerRef: firestoreRef(collectionPath('centers')),
publishedAt: z.date(),
tags: z.array(z.string()).default([]),
isPublished: z.boolean().default(false),
});
You can use dynamic path segments for documentation purposes:
export const schema = z.object({
// Path with dynamic segment (for documentation)
userPostRef: firestoreRef('users/:userId/posts'),
// This resolves to the same type as firestoreRef("users/posts")
// The :userId segment is filtered out during processing
});
References automatically resolve to properly typed DocumentReference objects:
// Server-side (Admin SDK)
const firetype = createFireTypeAdmin(db);
const post = await firetype.posts.getDocumentRef('post123').get();
const postData = post.data();
// postData.authorRef is typed as AdminDocumentReference<UserSchema>
// postData.collaboratorRefs is typed as AdminDocumentReference<UserSchema>[]
// postData.commentRef is typed as AdminDocumentReference<CommentSchema>
// Client-side (Web SDK)
const firetype = createFireTypeClient(db);
const post = await firetype.posts.getDocumentRef('post123').get();
const postData = post.data();
// postData.authorRef is typed as ClientDocumentReference<UserSchema>
// postData.collaboratorRefs is typed as ClientDocumentReference<UserSchema>[]
// postData.commentRef is typed as ClientDocumentReference<CommentSchema>
The generated types also include a union type of all valid collection paths for better development
experience:
// Generated type for type safety
export type DatabaseCollectionPaths =
| 'accounts'
| 'centers'
| 'matches'
| 'posts'
| 'users'
| 'users/comments';
Collection Path Types
For better type safety, you can use the CollectionPath type and collectionPath helper:
import { firestoreRef, collectionPath, type CollectionPath } from '@anonymous-dev/firetype';
// Create branded collection paths
const userPath: CollectionPath = collectionPath('users');
const postPath: CollectionPath = collectionPath('users/posts');
// Use with firestoreRef
const schema = z.object({
userRef: firestoreRef(userPath),
postRef: firestoreRef(postPath),
});
Complex Validation
// schemas/database/products/schema.ts
import { z } from 'zod';
export const schema = z
.object({
name: z.string().min(1).max(100),
description: z.string().max(1000),
price: z.number().positive(),
currency: z.enum(['USD', 'EUR', 'GBP']).default('USD'),
// Conditional validation
salePrice: z.number().positive().optional(),
onSale: z.boolean().default(false),
// Custom validation
inventory: z.object({
quantity: z.number().int().min(0),
lowStockThreshold: z.number().int().min(1).default(10),
sku: z.string().regex(/^[A-Z0-9]{8,12}$/, 'Invalid SKU format'),
}),
// Geopoint (use object for Firestore GeoPoint)
location: z
.object({
latitude: z.number().min(-90).max(90),
longitude: z.number().min(-180).max(180),
})
.optional(),
categories: z.array(z.string()).min(1, 'At least one category required'),
tags: z.array(z.string()).default([]),
createdAt: z.date(),
updatedAt: z.date(),
})
.refine((data) => !data.onSale || (data.salePrice && data.salePrice < data.price), {
message: 'Sale price must be less than regular price when on sale',
path: ['salePrice'],
});
Schema Best Practices
- Use descriptive names: Choose clear, descriptive names for your collections and fields
- Add validation: Leverage Zod’s validation features to ensure data integrity
- Use enums: For fields with a fixed set of values, use
z.enum() instead of strings
- Default values: Provide sensible defaults for optional fields
- Firestore references: Use
firestoreRef() for strongly-typed document references
- Collection paths: Use
collectionPath() helper for better type safety with references
- Dynamic path segments: Use
:param syntax in paths for documentation (e.g.,
"users/:userId/posts")
- Type references: Use TypeScript types when referencing other document IDs
- Custom validation: Add business logic validation using Zod’s
.refine() method
- Keep it DRY: Extract common schema parts into reusable constants
💡 Pro Tip: Your Zod schemas serve as both runtime validators and TypeScript type generators.
Well-crafted schemas provide excellent developer experience and data integrity.