Skip to content

InfoLoungeLLC/firestore-typed

Repository files navigation

@info-lounge/firestore-typed – Type-safe Firestore wrapper with runtime validation

npm version npm downloads codecov license

A type-safe, low-level wrapper for Firebase Firestore with mandatory runtime validation. This package ensures that all data is validated using typia validators during both read and write operations, providing comprehensive type safety, data integrity, and improved developer experience for Firestore operations.

日本語のREADMEはこちら / Japanese README here →

Quick Example - Why Use FirestoreTyped?

// ❌ Raw Firestore - No validation, runtime errors possible
const db = getFirestore()
await db.collection('users').doc('123').set({
  name: 123,      // Wrong type but no error until runtime
  email: null     // Missing required field
})

// ✅ FirestoreTyped - Automatic validation prevents errors
import { getFirestoreTyped } from '@info-lounge/firestore-typed'
import typia from 'typia'

interface User {
  name: string
  email: string
}

const userValidator = typia.createAssert<User>()
const db = getFirestoreTyped()
const users = db.collection<User>('users', userValidator)

await users.doc('123').set({
  name: 'John',              // ✓ Validated at runtime
  email: 'john@example.com'  // ✓ All fields checked
})
// Throws validation error if data doesn't match User interface

⚠️ EXPERIMENTAL RELEASE: This package is currently in beta. APIs may change in future releases. Use with caution in production environments.

Key Features

  • 🛡️ Mandatory Runtime Validation: All data is automatically validated using typia validators on read/write operations
  • 🔒 Type Safety: Full TypeScript compile-time and runtime type checking
  • ⚡ Performance Optimized: Minimal overhead with maximum data integrity
  • 🎯 Firebase Native: Direct mapping to Firestore's native API patterns
  • 🔧 Configurable: Flexible validation settings per operation or globally

Table of Contents

  1. Installation
  2. Quick Start
  3. Core Architecture
  4. Configuration
  5. CRUD Operations
  6. Query Builder
  7. Validation
  8. Automatic Type Conversion
  9. Type-Safe Document References
  10. Error Handling
  11. Performance
  12. Best Practices
  13. Advanced Usage
  14. API Reference

Installation

This is a private npm package within the workspace:

npm install @info-lounge/firestore-typed

Dependencies

This package requires the following peer dependencies:

# Required
npm install firebase-admin

# Choose one validation library (or both):
npm install typia     # Recommended for best performance
# OR
npm install zod       # Alternative validation library

Note: You must install either typia or zod (or both) as a validation library. The package will work with either one.

Typia Transform Configuration

Important: Typia requires TypeScript transform plugin configuration to work properly. Use ts-patch for the easiest setup.

1. Install ts-patch

npm install -D ts-patch

2. Patch TypeScript compiler

npx ts-patch install

3. Configure tsconfig.json

{
  "compilerOptions": {
    "strict": true,
    "plugins": [
      {
        "transform": "typia/lib/transform"
      }
    ]
  }
}

4. Use patched TypeScript compiler

# Instead of tsc
npx tsc

# For ts-node
npx ts-node your-file.ts

Note: Without proper transformer configuration, typia.createAssert<T>() will not generate validation code and may throw runtime errors. The ts-patch approach is the most reliable method for most projects.

Quick Start

Why FirestoreTyped?

FirestoreTyped's core principle: Every piece of data is validated. Unlike raw Firestore operations, FirestoreTyped ensures data integrity by requiring validators for all operations.

// ❌ Raw Firestore - No validation, potential runtime errors
const db = getFirestore()
await db.collection('users').doc('123').set({
  name: 123, // Wrong type, but no error until runtime
  email: null // Missing required field
})

// ✅ FirestoreTyped - Automatic validation prevents errors
const db = getFirestoreTyped()
const users = db.collection<UserEntity>('users', userValidator)
await users.doc('123').set({
  name: 'John', // Validated at runtime
  email: 'john@example.com' // All fields checked
})
// Throws validation error if data doesn't match UserEntity

Basic Usage

import { getFirestoreTyped } from '@info-lounge/firestore-typed'
import typia from 'typia'

// 1. Define your data structure
interface UserEntity {
  name: string
  email: string
  createdAt: Date
  updatedAt: Date
}

// 2. Create validator (REQUIRED - this is what makes data safe)
const userEntityValidator = typia.createAssert<UserEntity>()

// 3. Create FirestoreTyped instance
const db = getFirestoreTyped(undefined, { 
  validateOnWrite: true,  // Default: validates all writes
  validateOnRead: false   // Optional: validates all reads
})

// 4. Create typed collection with validator
const usersCollection = db.collection<UserEntity>('users', userEntityValidator)

// ✅ This data will be validated before write
await usersCollection.doc('user-001').set({
  name: 'John Doe',
  email: 'john@example.com',
  createdAt: new Date(),
  updatedAt: new Date()
})

// ✅ This data will be validated after read (if validateOnRead: true)
const user = await usersCollection.doc('user-001').get()
// user.data is guaranteed to match UserEntity or throw validation error

Using Custom Firestore Instances

When you need to use a different Firestore instance (e.g., different project, different database), you can pass a custom Firestore instance:

import { getFirestore } from 'firebase-admin/firestore'
import { initializeApp, getApps } from 'firebase-admin/app'
import { getFirestoreTyped } from '@info-lounge/firestore-typed'

// Initialize custom Firebase app
const customApp = getApps().find(app => app.name === 'custom-app') || 
  initializeApp({
    projectId: 'custom-project-id',
    // other configuration...
  }, 'custom-app')

// Get custom Firestore instance
const customFirestore = getFirestore(customApp)

// Initialize FirestoreTyped with custom Firestore instance
const customDb = getFirestoreTyped(customFirestore, {
  validateOnWrite: true,
  validateOnRead: false
})

// Use as normal
const usersCollection = customDb.collection<UserEntity>('users', userValidator)
await usersCollection.doc('user-001').set(userData)

// Using named databases
const namedDatabase = getFirestore(customApp, '(named-database)')
const namedDb = getFirestoreTyped(namedDatabase, {
  validateOnWrite: true
})

Use Cases:

  • Multi-project: Working with multiple Firebase projects
  • Environment separation: Different databases for development, staging, production
  • Named databases: Using Firestore's multiple database feature
  • Testing: Using dedicated Firestore emulator instances

Complete CRUD Example

// Define user data structure
interface User {
  name: string
  email: string
  createdAt: Date
  updatedAt: Date
}

// Create validator function
const userValidator = typia.createAssert<User>()

// Initialize FirestoreTyped
const db = getFirestoreTyped(undefined, {
  validateOnRead: true,
  validateOnWrite: true
})

const usersCollection = db.collection<User>('users', userValidator)

// CREATE: Add document with auto-generated ID
const newUser = {
  name: 'John Doe',
  email: 'john@example.com',
  createdAt: new Date(),
  updatedAt: new Date()
}

const docRef = await usersCollection.add(newUser)
console.log('Created user with ID:', docRef.id)

// CREATE: Set document with specific ID
const specificUser = {
  name: 'Jane Smith',
  email: 'jane@example.com',
  createdAt: new Date(),
  updatedAt: new Date()
}

await usersCollection.doc('user-123').set(specificUser)

// READ: Get single document
const userSnapshot = await usersCollection.doc('user-123').get()
if (userSnapshot.metadata.exists) {
  const user = userSnapshot.data!
  console.log(`User: ${user.name} (${user.email})`)
}

// READ: Get all documents
const allUsers = await usersCollection.get()
const userList = allUsers.docs.map(doc => ({
  id: doc.metadata.id, // Use Firestore document ID
  ...doc.data!
}))

// UPDATE: Merge partial data
await usersCollection.doc('user-123').merge({
  email: 'jane.updated@example.com',
  updatedAt: new Date()
})

// UPDATE: Complete replacement
const updatedUser: User = {
  name: 'Jane Smith',
  email: 'jane.smith@example.com',
  createdAt: new Date('2024-01-01'),
  updatedAt: new Date()
}
await usersCollection.doc('user-123').set(updatedUser)

// DELETE: Remove document
await usersCollection.doc('user-123').delete()

Core Architecture

FirestoreTyped Instance

FirestoreTyped provides a low-level, type-safe interface to Firestore with built-in validation. The main entry point is the getFirestoreTyped() factory function:

const db = getFirestoreTyped(firestore?: Firestore, options?: FirestoreTypedOptions)

Architecture Philosophy

FirestoreTyped follows a low-level, collection-specific validation approach:

  • Type Safety: Generic types ensure compile-time safety
  • Flexible Validation: Collection-level validators for different entity types
  • Firebase Native: Direct mapping to Firestore's native API patterns
  • Performance Focused: Minimal overhead with maximum type safety

Collection Reference

Collections are accessed through the collection() method with type-specific validator:

// Validator is provided per collection for maximum flexibility
const collection = db.collection<T>(path: string, validator: (data: unknown) => T)

Document Reference

Documents are accessed through the doc() method on collections:

const doc = collection.doc(id: string)

Document Snapshot

Read operations return a DocumentSnapshot<T> with type-safe data access:

const snapshot = await doc.get()
const data = snapshot.data // Type-safe data access with automatic validation
const exists = snapshot.metadata.exists

Configuration

Global Options

interface FirestoreTypedOptions {
  validateOnRead?: boolean   // Default: false
  validateOnWrite?: boolean  // Default: true
}

Validator Configuration

FirestoreTyped uses collection-level validators for flexible entity type support:

// Using typia for compile-time type generation
import typia from 'typia'

interface UserEntity {
  name: string
  email: string
  createdAt: Date
  updatedAt: Date
}

// Create validator function
const userValidator = typia.createAssert<UserEntity>()

// Initialize FirestoreTyped
const db = getFirestoreTyped(undefined, {
  validateOnRead: true,
  validateOnWrite: true
})

// Create typed collection with validator
const usersCollection = db.collection<UserEntity>('users', userValidator)

Operation Options

interface ReadOptions {
  validateOnRead?: boolean  // Override global setting
}

interface WriteOptions {
  validateOnWrite?: boolean  // Override global setting
  failIfExists?: boolean    // Prevent overwriting existing documents
}

CRUD Operations

All CRUD operations require a typed collection created with a validator:

// Setup: Create typed collection
const db = getFirestoreTyped()
const collection = db.collection<UserEntity>('users', userValidator)

Create Documents

// Add with auto-generated ID
await collection.add(data)

// Set with specific ID
await collection.doc('specific-id').set(data)

// Set with write options
await collection.doc('id').set(data, { validateOnWrite: false })

// Ensure document creation without overwriting
await collection.doc('id').set(data, { failIfExists: true })
// Throws DocumentAlreadyExistsError if document already exists

Read Documents

// Get single document
const snapshot = await collection.doc('id').get()
const data = snapshot.data

// Get single document with read options
const snapshot = await collection.doc('id').get({ validateOnRead: true })

// Get all documents in collection
const querySnapshot = await collection.get()
const documents = querySnapshot.docs.map(doc => doc.data)

Merge Partial Data

Important:

  • FirestoreTyped uses a dedicated merge() method instead of set(data, { merge: true })
  • The merge operation validates the complete merged data, not just the partial data being merged
  • The document must exist - throws DocumentNotFoundError if the document doesn't exist
  • Native Firestore's set(..., { merge: true }) is not available in FirestoreTyped
// Partial merge with existing data
// 1. Retrieves existing document data
// 2. Merges with new partial data
// 3. Validates the COMPLETE merged result against the entity type
await collection.doc('id').merge({ field: 'newValue' })

// Example with validation
interface User {
  name: string
  email: string
  age: number
}

// Existing document: { name: 'John', email: 'john@example.com', age: 30 }
// Merge operation:
await usersCollection.doc('user123').merge({ 
  email: 'john.doe@example.com' 
})
// Result validated: { name: 'John', email: 'john.doe@example.com', age: 30 }
// ✅ Passes validation as complete User entity

// ❌ This would fail validation:
// await usersCollection.doc('user123').merge({ invalidField: 'value' })
// Because merged result would have extra field not in User interface

// Merge with validation options
await collection.doc('id').merge(data, { validateOnWrite: true })

// ❌ NOT available in FirestoreTyped (use merge() instead):
// await collection.doc('id').set(data, { merge: true }) // This pattern is NOT supported

// ✅ Use dedicated merge() method:
await collection.doc('id').merge(data) // This is the correct FirestoreTyped way

// Handle non-existent document error
try {
  await collection.doc('non-existent').merge({ field: 'value' })
} catch (error) {
  if (error instanceof DocumentNotFoundError) {
    console.log('Document does not exist:', error.documentPath)
    // Create the document instead
    await collection.doc('non-existent').set(fullData)
  }
}

Delete Documents

await collection.doc('id').delete()

Query Builder

FirestoreTyped provides a complete type-safe query builder that allows you to use Firebase Firestore's powerful query capabilities with full type safety.

Basic Queries

// Setup: Create typed collection
const db = getFirestoreTyped()
const usersCollection = db.collection<UserEntity>('users', userValidator)

// Single condition search
const johnUsers = await usersCollection
  .where('name', '==', 'John Doe')
  .get()

// Sorting functionality
const sortedUsers = await usersCollection
  .orderBy('createdAt', 'desc')
  .get()

// Result limiting
const latestUsers = await usersCollection
  .orderBy('updatedAt', 'desc')
  .limit(10)
  .get()

Complex Queries and Method Chaining

// Multiple condition combined query
const filteredUsers = await usersCollection
  .where('email', '>=', 'a@example.com')
  .where('email', '<=', 'z@example.com')
  .orderBy('name', 'asc')
  .limit(5)
  .get()

// Product type-based search
const cellProducts = await productsCollection
  .where('category', '==', 'electronics')
  .where('price', '<=', 1000)
  .orderBy('name')
  .get()

Advanced Pagination

// Cursor-based pagination
const firstPage = await usersCollection
  .orderBy('createdAt', 'desc')
  .limit(20)
  .get()

if (firstPage.docs.length > 0) {
  const lastDoc = firstPage.docs[firstPage.docs.length - 1]
  
  // Get next page
  const secondPage = await usersCollection
    .orderBy('createdAt', 'desc')
    .startAfter(lastDoc.data!.createdAt)
    .limit(20)
    .get()
}

// Range queries
const rangeQuery = await usersCollection
  .orderBy('name')
  .startAt('Alice')
  .endAt('John')
  .get()

Type Safety

The query builder provides type safety for field names and basic operations:

// ✅ Valid usage - field names are type-checked
const validQuery = usersCollection.where('name', '==', 'John Doe')
const sortedQuery = usersCollection.orderBy('createdAt', 'desc')

// ❌ Compile errors for invalid field names
// const invalidQuery = usersCollection.where('invalidField', '==', 'value')

// ⚠️ Note: Value types and pagination parameters are not fully type-checked
// const query = usersCollection.where('name', '==', 123) // May not catch type errors
// const paginated = usersCollection.startAt('any', 'values') // Parameters are unknown[]

Validation Integration

// Query results automatically validated based on global configuration
const validatedResults = await usersCollection
  .where('name', '!=', '')
  .orderBy('name')
  .get()

// Override global validation settings for specific operations
const fastResults = await usersCollection
  .limit(100)
  .get({ validateOnRead: false })

Native Firebase Access

For advanced Firestore features, you can access the native query object:

const query = usersCollection.where('name', '!=', '').orderBy('name')
const nativeQuery = query.native // Firebase Query object
const snapshot = await nativeQuery.get()

Validation

Runtime Validation with Typia

FirestoreTyped integrates with typia for runtime validation:

import typia from 'typia'

interface UserEntity {
  name: string
  email: string
  createdAt: Date
  updatedAt: Date
}

const userEntityValidator = typia.createAssert<UserEntity>()
const db = getFirestoreTyped()
const usersCollection = db.collection<UserEntity>('users', userEntityValidator)

// This will throw a validation error if data doesn't match UserEntity
await usersCollection.doc('user-001').set(invalidData)

Advanced Typia Features

Typia supports JSDoc comment-based type annotations for more precise validation:

interface UserEntity {
  /**
   * User ID with specific format
   * @format uuid
   */
  id: string

  /**
   * User name with length constraints
   * @minLength 2
   * @maxLength 50
   */
  name: string

  /**
   * Email address with format validation
   * @format email
   */
  email: string

  /**
   * User age as integer
   * @type integer
   * @minimum 0
   * @maximum 120
   */
  age: number

  /**
   * Phone number with regex pattern
   * @pattern ^\+?[1-9]\d{1,14}$
   */
  phone: string

  /**
   * User status from specific values
   * @items.enum ["active", "inactive", "pending"]
   */
  status: "active" | "inactive" | "pending"

  createdAt: Date
  updatedAt: Date
}

const userValidator = typia.createAssert<UserEntity>()
const db = getFirestoreTyped()

// This will validate all the constraints:
// - id must be valid UUID format
// - name must be 2-50 characters
// - email must be valid email format
// - age must be integer between 0-120
// - phone must match the regex pattern
// - status must be one of the allowed values
await db.collection<UserEntity>('users', userValidator).doc('user123').set({
  id: '550e8400-e29b-41d4-a716-446655440000',
  name: 'John Doe',
  email: 'john@example.com',
  age: 30,
  phone: '+1234567890',
  status: 'active',
  createdAt: new Date(),
  updatedAt: new Date()
})

Supported JSDoc Tags:

  • @type integer - Validates integer numbers
  • @format email|uuid|date|uri - Format validation
  • @pattern <regex> - Regular expression validation
  • @minimum/@maximum - Numeric range validation
  • @minLength/@maxLength - String length validation
  • @items.enum - Enum value validation

This provides much more precise validation than basic TypeScript types alone.

Alternative Validation Libraries

Note: FirestoreTyped accepts any validator function with signature (data: unknown) => T. Besides typia, we have also verified compatibility with Zod:

// Zod example
import { z } from 'zod'

const UserSchema = z.object({
  name: z.string(),
  email: z.string(),
  createdAt: z.date(),
  updatedAt: z.date()
})

const zodValidator = (data: unknown): UserEntity => {
  return UserSchema.parse(data) // Throws on validation failure
}

const db = getFirestoreTyped()
const users = db.collection<UserEntity>('users', zodValidator)

// Joi example (untested)
import Joi from 'joi'

const userJoiSchema = Joi.object({
  name: Joi.string().required(),
  email: Joi.string().required(),
  createdAt: Joi.date().required(),
  updatedAt: Joi.date().required()
})

const joiValidator = (data: unknown): UserEntity => {
  const { error, value } = userJoiSchema.validate(data)
  if (error) throw error
  return value
}

const db2 = getFirestoreTyped()
const users2 = db2.collection<UserEntity>('users', joiValidator)

⚠️ Important: We have verified compatibility with typia and Zod. Other validation libraries like Joi should work in theory but have not been verified. Please test thoroughly and report any issues.

Validation Control

// Enable validation for a specific operation
await usersCollection.doc('user-001').set(data, { validateOnWrite: true })

// Disable validation for a specific operation
await usersCollection.doc('user-001').set(data, { validateOnWrite: false })

Validation Errors

import { FirestoreTypedValidationError } from '@info-lounge/firestore-typed'

try {
  await usersCollection.doc('user-001').set(invalidData)
} catch (error) {
  if (error instanceof FirestoreTypedValidationError) {
    console.log('Validation failed:', error.message)
    console.log('Original error:', error.originalError)
  }
}

Error Handling

// Setup: Create typed collection
const db = getFirestoreTyped()
const usersCollection = db.collection<UserEntity>('users', userValidator)

Document Not Found

try {
  const snapshot = await usersCollection.doc('99999').get()
  if (!snapshot.metadata.exists) {
    console.log('User does not exist')
  }
} catch (error) {
  console.error('Error retrieving user:', error)
}

Validation Errors

try {
  await usersCollection.doc('user-001').set(invalidData)
} catch (error) {
  if (error instanceof FirestoreTypedValidationError) {
    // Handle validation error
  }
}

Document Already Exists

import { DocumentAlreadyExistsError } from '@info-lounge/firestore-typed'

try {
  // This will fail if document already exists
  await usersCollection.doc('user-001').set(data, { failIfExists: true })
} catch (error) {
  if (error instanceof DocumentAlreadyExistsError) {
    console.log('Document already exists at:', error.documentPath)
    // Handle by updating instead of creating
    await usersCollection.doc('user-001').merge(data)
  }
}

Automatic Type Conversion

FirestoreTyped automatically handles the conversion of JavaScript types to Firestore special types during write operations:

Supported Type Conversions

JavaScript Type Firestore Type Description
Date Timestamp DateTime data conversion (see precision note below)
SerializedGeoPoint GeoPoint Geographic location data conversion
SerializedDocumentReference<TCollection, TDocument> DocumentReference Type-safe document reference restoration

⚠️ Important Note on Date/Timestamp Precision: JavaScript Date objects have millisecond precision, while Firestore Timestamp objects support nanosecond precision. When converting from Date to Timestamp, the nanosecond portion will always be 000000 (zero). This means any nanosecond-level precision from the original Firestore data will be lost during the conversion process.

Example Usage

import { GeoPoint, Timestamp } from 'firebase-admin/firestore'
import type { StoreEntity } from '@common/types/store.types'

// JavaScript types are automatically converted to Firestore types
const storeData = {
  id: 'store-001',
  name: 'Tokyo Electronics Store',
  address: 'Shibuya, Tokyo',
  phone: '+81-3-1234-5678',
  category: {
    type: 'DocumentReference',
    path: 'categories/electronics',
    collectionId: 'categories',
    documentId: 'electronics'
  } as SerializedDocumentReference<'categories', CategoryEntity>,
  location: {
    type: 'GeoPoint',
    latitude: 35.6762,
    longitude: 139.6503
  },
  createdAt: new Date('2024-01-01'),
  updatedAt: new Date()
}

// Setup: Create typed collection and automatically converts during write:
// - Date → Timestamp
// - SerializedGeoPoint → GeoPoint  
// - SerializedDocumentReference → DocumentReference
const db = getFirestoreTyped()
const storesCollection = db.collection<StoreEntity>('stores', storeValidator)
await storesCollection.doc('store-001').set(storeData)

Processing Order

  1. Validation: Data is validated using typia assertions
  2. Type Conversion: JavaScript types converted to Firestore types
  3. Write: Data written to Firestore with proper types

Type-Safe Document References

FirestoreTyped provides enhanced type safety for document references through the SerializedDocumentReference interface:

Generic Type Parameters

import { SerializedDocumentReference } from '@info-lounge/firestore-typed'

// TCollection: Collection name (literal type)
// TDocument: Referenced document type (phantom type)
type ProductRef = SerializedDocumentReference<'products', ProductEntity>

// Type-safe reference with compile-time validation
const parentRef: ProductRef = {
  type: 'DocumentReference',
  path: 'products/parent-001',
  collectionId: 'categories', // Must match 'products'
  documentId: 'parent-001'
}

Type Safety Benefits

  • Collection Name Validation: collectionId must match the generic type
  • Type Safety: TypeScript enforces correct collection-document type pairs
  • Runtime Checking: typia validates the structure and values
  • IDE Support: Full IntelliSense and autocomplete

Usage Example

// Creating products with parent references
const childProduct: ProductEntity = {
  id: 'child-001',
  name: 'Child Product',
  category: 'electronics',
  userId: 'user-001',
  parent: {
    type: 'DocumentReference',
    path: 'products/parent-001',
    collectionId: 'categories', // ✅ Type-safe
    documentId: 'parent-001'
  } as SerializedDocumentReference<'products', ProductEntity>,
  createdAt: new Date(),
  updatedAt: new Date()
}

Performance

Benchmarks

Note: These performance benchmarks are based on comprehensive testing using typia validators. Performance may vary with other validation libraries.

Based on comprehensive performance testing:

Core Operations Performance

  • Create Operations: High-performance document creation (avg. 57ms)
  • Read Operations: Optimized data retrieval (avg. 17ms)
  • Merge Operations: Efficient partial updates (avg. 44ms)
  • Batch Operations: Optimized bulk operations (avg. 72ms)

Type Conversion Performance

  • SerializedDocumentReference conversion: <0.1ms overhead per reference
  • Complex nested structure processing: Maintains ~98% of baseline performance
  • Type guard validation: Efficient type checking with minimal performance impact

Validation Overhead

  • Runtime validation adds approximately 1.2% overhead (0.58ms)
  • SerializedDocumentReference validation adds <0.1ms per reference
  • Validation can be disabled per operation for performance-critical paths
  • Type guards ensure safe conversion without performance penalties

Memory Usage

  • Minimal memory footprint (271KB/operation)
  • Efficient handling of large datasets with document references
  • No memory leaks in continuous operations
  • SerializedDocumentReference conversion uses minimal additional memory

Advanced Usage

Options Management

// Initialize with default options
const db = getFirestoreTyped()

// Get current options
const currentOptions = db.getOptions()

// Create new instance with different options
const strictDb = db.withOptions({ validateOnRead: true })

Type Safety

// Setup: Create typed collection
const db = getFirestoreTyped()
const users = db.collection<UserEntity>('users', userValidator)

// TypeScript will enforce correct types
await users.doc('user-001').set({
  name: 'John Doe',
  email: 'john@example.com',
  createdAt: new Date(),
  updatedAt: new Date()
})

// This will cause a compile-time error
// await users.doc('user-001').set({
//   id: 'user-001',
//   name: 'John Doe',
//   email: 123 // Type error: number is not assignable to string
// })

Working with Existing Types

// Integration with your application types including document references
import { SerializedDocumentReference } from '@info-lounge/firestore-typed'
import typia from 'typia'

// Define your entity types
interface UserEntity {
  name: string
  email: string
  createdAt: Date
  updatedAt: Date
}

interface ProductEntity {
  id: string
  name: string
  category: string
  price: number
  parentCategory?: SerializedDocumentReference<'categories', CategoryEntity>
  createdAt: Date
  updatedAt: Date
}

// Create validators
const userValidator = typia.createAssert<UserEntity>()
const productValidator = typia.createAssert<ProductEntity>()

// Create single FirestoreTyped instance that can handle multiple entity types
const db = getFirestoreTyped()

// Create type-safe collections with their respective validators
const users = db.collection<UserEntity>('users', userValidator)
const products = db.collection<ProductEntity>('products', productValidator)

// Working with products that have parent references
const productWithParent: ProductEntity = {
  id: 'prod-001',
  name: 'Test Product',
  category: 'electronics',
  userId: 'user-001',
  parent: {
    type: 'DocumentReference',
    path: 'categories/electronics-main',
    collectionId: 'categories',
    documentId: 'electronics-main'
  } as SerializedDocumentReference<'products', ProductEntity>,
  createdAt: new Date(),
  updatedAt: new Date()
}

Best Practices

1. Always Use Validators

FirestoreTyped requires validators for type safety and data integrity:

// ✅ Good: Always provide a validator per collection
const userValidator = typia.createAssert<UserEntity>()
const db = getFirestoreTyped()
const users = db.collection<UserEntity>('users', userValidator)

// ❌ Bad: Never skip validation
// This is not possible with FirestoreTyped's design

2. Type Your Data Properly

// ✅ Good: Use specific types
interface UserEntity {
  name: string
  email: string
  createdAt: Date
  updatedAt: Date
}

// ❌ Bad: Avoid generic types
// Don't use 'any' or overly broad types

3. Handle Errors Appropriately

import { FirestoreTypedValidationError } from '@info-lounge/firestore-typed'

// Setup: Create typed collection
const db = getFirestoreTyped()
const userCollection = db.collection<UserEntity>('users', userValidator)

try {
  await userCollection.doc('user-id').set(userData)
} catch (error) {
  if (error instanceof FirestoreTypedValidationError) {
    // Handle validation error
    console.error('Data validation failed:', error.message)
  } else {
    // Handle other errors
    throw error
  }
}

4. Optimize Validation Settings

// For read-heavy operations, consider disabling read validation
const fastDb = getFirestoreTyped(undefined, {
  validateOnRead: false,  // Skip validation on reads for performance
  validateOnWrite: true   // Always validate writes for data integrity
})

const userCollection = fastDb.collection<UserEntity>('users', userValidator)

// Override per operation when needed
const data = await userCollection.doc('user-id').get({ validateOnRead: true })

5. Use Collection Group Queries Efficiently

// ✅ Good: Use collection group queries for cross-collection searches
const allProducts = await db.queryCollectionGroup('products', (query) =>
  query.where('category', '==', 'electronics').orderBy('name')
)

// ✅ Good: Regular collection queries for single collection
const userProducts = await db.collection('users/user-001/products').get()

6. Performance Considerations

// ✅ For high-frequency operations, consider validation overhead
const performanceDb = getFirestoreTyped(undefined, {
  validateOnRead: false,   // Skip validation for read-heavy logging
  validateOnWrite: false   // Skip validation for high-frequency writes
})
const logs = performanceDb.collection<LogEntry>('logs', logValidator)

// ✅ Use batch operations for multiple writes (planned feature)
// const batch = db.batch() // ⚠️ Not yet implemented
// batch.set(userCollection.doc('user1'), userData1)
// batch.set(userCollection.doc('user2'), userData2)
// await batch.commit()

// For now, use native Firestore batch:
const batch = db.native.batch()
batch.set(db.native.collection('users').doc('user1'), userData1)
batch.set(db.native.collection('users').doc('user2'), userData2)
await batch.commit()

API Reference

Factory Function

/**
 * Creates a FirestoreTyped instance for multi-entity type support
 * @param options - Global configuration options
 * @returns Configured FirestoreTyped instance
 * @example
 * ```typescript
 * import { getFirestoreTyped } from '@info-lounge/firestore-typed'
 * 
 * const db = getFirestoreTyped(firestore, options)
 * ```
 */
function getFirestoreTyped(
  options?: FirestoreTypedOptions
): FirestoreTyped

FirestoreTyped Instance

/**
 * Main FirestoreTyped instance providing type-safe Firestore operations
 */
class FirestoreTyped {
  /**
   * Gets a typed collection reference with validator
   * @param path - Firestore collection path
   * @param validator - Runtime validation function for the entity type
   * @returns Type-safe collection reference
   * @example
   * ```typescript
   * const usersCollection = db.collection<UserEntity>('users', userValidator)
   * const userProductsCollection = db.collection<ProductEntity>('users/user-001/products', productValidator)
   * ```
   */
  collection<T>(path: string, validator: (data: unknown) => T): CollectionReference<T>

  /**
   * Gets a typed collection group reference with validator
   * @param collectionId - Collection group ID to query across multiple parent documents
   * @param validator - Runtime validation function for the entity type
   * @returns Type-safe collection group reference
   * @example
   * ```typescript
   * const allPosts = db.collectionGroup<PostEntity>('posts', postValidator)
   * const userPosts = allPosts.where('userId', '==', 'user123')
   * ```
   */
  collectionGroup<T>(collectionId: string, validator: (data: unknown) => T): CollectionGroup<T>

  /**
   * Gets current configuration options
   * @returns Current FirestoreTyped options
   * @example
   * ```typescript
   * const currentOptions = db.getOptions()
   * console.log(`Validation on read: ${currentOptions.validateOnRead}`)
   * ```
   */
  getOptions(): ResolvedFirestoreTypedOptions

  /**
   * Creates new instance with modified options
   * @param options - Partial options to override
   * @returns New FirestoreTyped instance with updated options
   * @example
   * ```typescript
   * const strictDb = db.withOptions({ validateOnRead: true })
   * const fastDb = db.withOptions({ validateOnWrite: false })
   * ```
   */
  withOptions(options: Partial<FirestoreTypedOptions>): FirestoreTyped

  /**
   * Access to native Firestore instance for advanced operations
   * @returns Native Firestore instance
   * @example
   * ```typescript
   * const nativeFirestore = db.native
   * const batch = nativeFirestore.batch()
   * ```
   */
  get native(): Firestore

  /**
   * Performs collection group query across multiple collections
   * @param collectionId - Collection ID to search across
   * @param queryFn - Optional query builder function
   * @returns Query results from all matching collections
   * @throws FirestoreTypedValidationError if validation fails
   * @example
   * ```typescript
   * // Find all products across all users
   * const allProducts = await db.queryCollectionGroup('products')
   * 
   * // With query constraints
   * const electronicsProducts = await db.queryCollectionGroup('products', (query) =>
   *   query.where('category', '==', 'electronics').orderBy('name')
   * )
   * ```
   */
  queryCollectionGroup<T>(
    collectionId: string, 
    queryFn?: (query: Query) => Query
  ): Promise<QuerySnapshot<T>>

  /**
   * Finds specific document across collection groups
   * @param collectionId - Collection ID to search in
   * @param documentId - Document ID to find
   * @returns Document data if found, null otherwise
   * @throws FirestoreTypedValidationError if validation fails
   * @example
   * ```typescript
   * // Find user across all users/products
   * const user = await db.findDocumentInCollectionGroup('publicUsers', 'user123')
   * if (user) {
   *   console.log(`Found user: ${user.name}`)
   * }
   * ```
   */
  findDocumentInCollectionGroup<T>(
    collectionId: string, 
    documentId: string
  ): Promise<T | null>
}

CollectionReference Class

/**
 * Type-safe collection reference with query capabilities
 */
class CollectionReference<T> {
  /**
   * Collection ID (last segment of path)
   * @example 'users', 'products'
   */
  get id(): string

  /**
   * Full collection path
   * @example 'users', 'users/user-001/products'
   */
  get path(): string

  /**
   * Gets a document reference within this collection
   * @param id - Document ID
   * @returns Type-safe document reference
   * @example
   * ```typescript
   * const userDoc = usersCollection.doc('user123')
   * const userData = await userDoc.get()
   * ```
   */
  doc(id: string): DocumentReference<T>

  /**
   * Adds a new document with auto-generated ID
   * @param data - Document data to add
   * @param options - Write options (validation settings)
   * @returns Promise resolving to document reference
   * @throws FirestoreTypedValidationError if validation fails
   * @example
   * ```typescript
   * const docRef = await usersCollection.add({
   *   name: 'John Doe',
   *   email: 'john@example.com',
   *   createdAt: new Date(),
   *   updatedAt: new Date()
   * })
   * console.log(`Created with ID: ${docRef.id}`)
   * ```
   */
  add(data: T, options?: WriteOptions): Promise<DocumentReference<T>>

  /**
   * Gets all documents in the collection
   * @param options - Read options (validation settings)
   * @returns Promise resolving to query snapshot
   * @throws FirestoreTypedValidationError if validation fails
   * @example
   * ```typescript
   * const snapshot = await usersCollection.get()
   * const users = snapshot.docs.map(doc => doc.data!)
   * console.log(`Found ${users.length} users`)
   * ```
   */
  get(options?: ReadOptions): Promise<QuerySnapshot<T>>

  /**
   * Creates a query with where clause
   * @param field - Field to filter on
   * @param operator - Comparison operator
   * @param value - Value to compare against
   * @returns Query builder for method chaining
   * @example
   * ```typescript
   * const activeUsers = await usersCollection
   *   .where('status', '==', 'active')
   *   .get()
   * ```
   */
  where(field: keyof T, operator: WhereFilterOp, value: any): Query<T>

  /**
   * Orders query results by specified field
   * @param field - Field to order by
   * @param direction - Sort direction ('asc' | 'desc')
   * @returns Query builder for method chaining
   * @example
   * ```typescript
   * const sortedUsers = await usersCollection
   *   .orderBy('createdAt', 'desc')
   *   .limit(10)
   *   .get()
   * ```
   */
  orderBy(field: keyof T, direction?: 'asc' | 'desc'): Query<T>

  /**
   * Limits number of results
   * @param limit - Maximum number of documents to return
   * @returns Query builder for method chaining
   */
  limit(limit: number): Query<T>
}

DocumentReference Class

/**
 * Type-safe document reference for individual document operations
 */
class DocumentReference<T> {
  /**
   * Document ID
   */
  get id(): string

  /**
   * Full document path
   * @example 'users/user123', 'users/user-001/products/prod456'
   */
  get path(): string

  /**
   * Gets document data
   * @param options - Read options (validation settings)
   * @returns Promise resolving to document snapshot
   * @throws FirestoreTypedValidationError if validation fails
   * @example
   * ```typescript
   * const snapshot = await userDoc.get()
   * if (snapshot.metadata.exists) {
   *   console.log(`User: ${snapshot.data!.name}`)
   * } else {
   *   console.log('User not found')
   * }
   * ```
   */
  get(options?: ReadOptions): Promise<DocumentSnapshot<T>>

  /**
   * Sets document data (creates or replaces)
   * @param data - Complete document data
   * @param options - Write options (validation settings, failIfExists)
   * @returns Promise resolving when operation completes
   * @throws FirestoreTypedValidationError if validation fails
   * @throws DocumentAlreadyExistsError if failIfExists is true and document exists
   * @example
   * ```typescript
   * // Normal set operation
   * await userDoc.set({
   *   id: 'user123',
   *   name: 'John Doe',
   *   email: 'john@example.com',
   *   createdAt: new Date(),
   *   updatedAt: new Date()
   * })
   * 
   * // Ensure document doesn't exist
   * await userDoc.set(data, { failIfExists: true })
   * ```
   */
  set(data: T, options?: WriteOptions): Promise<void>

  /**
   * Merges partial data with existing document
   * @param data - Partial document data to merge
   * @param options - Write options (validation settings)
   * @returns Promise resolving when operation completes
   * @throws DocumentNotFoundError if document doesn't exist
   * @throws FirestoreTypedValidationError if validation fails
   * @example
   * ```typescript
   * await userDoc.merge({
   *   email: 'newemail@example.com',
   *   updatedAt: new Date()
   * })
   * 
   * // Error handling
   * try {
   *   await userDoc.merge(data)
   * } catch (error) {
   *   if (error instanceof DocumentNotFoundError) {
   *     // Document doesn't exist, create it instead
   *     await userDoc.set(fullData)
   *   }
   * }
   * ```
   */
  merge(data: Partial<T>, options?: WriteOptions): Promise<void>

  /**
   * Deletes the document
   * @returns Promise resolving when deletion completes
   * @example
   * ```typescript
   * await userDoc.delete()
   * console.log('User deleted successfully')
   * ```
   */
  delete(): Promise<void>
}

Types

interface FirestoreTypedOptions {
  validateOnRead?: boolean
  validateOnWrite?: boolean
}

interface CollectionOptions<T> {
  validator?: (data: unknown) => T
}

interface ReadOptions {
  validateOnRead?: boolean
}

interface WriteOptions {
  validateOnWrite?: boolean
  failIfExists?: boolean    // If true, throws error if document already exists
}

interface DocumentSnapshot<T> {
  metadata: DocumentMetadata
  data?: T
}

interface QuerySnapshot<T> {
  docs: DocumentSnapshot<T>[]
  empty: boolean
  size: number
}