Dot Notation for Nested Updates
Update individual nested fields in place — without replacing the whole parent object — using Firestore’s dot-notation field paths.
FlintFire supports Firestore’s dot-notation syntax for updating nested fields without replacing entire objects. This lets you change specific nested properties while preserving the other fields already stored in that object.
Dot-notation is type-safe and schema-validated in v3: nested field paths are typed against
your model (no as any), invalid leaf values are rejected, and a dotted key that isn’t part of your
schema throws instead of being silently dropped. See
Type Safety & Validation below.
Basic Nested Update
Section titled “Basic Nested Update”// Without dot notation - replaces entire address objectawait userRepo.update('user-123', { address: { city: 'Los Angeles', },});// Result: { address: { city: 'Los Angeles' } }// street, zipCode, and other fields are lost
// With dot notation - updates only city, preserves other fieldsawait userRepo.update('user-123', { 'address.city': 'Los Angeles',});// Result: { address: { city: 'Los Angeles', street: '123 Main', zipCode: '90001' } }// street and zipCode are preservedDeep Nested Updates
Section titled “Deep Nested Updates”// Update deeply nested settingsawait userRepo.update('user-123', { 'profile.settings.notifications.email': true, 'profile.settings.theme': 'dark',});
// Creates nested structure if it doesn't existawait userRepo.update('user-123', { 'metadata.preferences.language': 'en', 'metadata.preferences.timezone': 'UTC',});Mixed Updates
Section titled “Mixed Updates”// Combine regular fields with dot notationawait userRepo.update('user-123', { name: 'John Doe', // Regular field 'address.city': 'New York', // Nested field 'address.zipCode': '10001', // Another nested field 'profile.verified': true, // Different nested object});Update with Merge Mode
Section titled “Update with Merge Mode”update(id, data, { merge: true }) normalizes nested objects into dot-notation update paths and
uses Firestore update(...) under the hood. This lets you pass a natural nested object and still
get field-level merge semantics instead of whole-object replacement.
await userRepo.update( 'user-123', { 'profile.settings.theme': 'dark', }, { merge: true },);Because it stays on the update(...) code path, update() semantics are preserved: a missing
document still throws NotFoundError.
When normalizing, explicit dot-notation keys always win over paths derived from flattening a nested
object. For example, an explicit 'profile.name' overrides the profile.name that would otherwise
be produced by flattening profile: { name: ... } in the same payload.
update() accepts { merge?, returnDoc?, withMetadata?, lastUpdateTime? } (see
UpdateOptions). merge defaults to false (whole-object
replacement); set returnDoc: true to get the persisted document back instead of just { id }.
Patch Convenience Alias
Section titled “Patch Convenience Alias”Use patch(...) as a convenience alias for update(..., { merge: true }). patch() always
merges — there is no merge option to toggle; it accepts
{ returnDoc?, withMetadata?, lastUpdateTime? }. It applies the
same nested-object-to-dot-notation normalization as merge-mode update(), so you can pass either
nested objects or explicit dot-notation keys.
await userRepo.patch('user-123', { profile: { settings: { theme: 'dark', }, },});Merge/Patch/BulkPatch Limitation
Section titled “Merge/Patch/BulkPatch Limitation”Literal field names that contain a dot (.) are not supported by the merge/patch/bulkPatch
normalization. A dot-containing key is always interpreted as a nested field path, never as a single
top-level field whose name happens to include a dot.
Bulk Updates with Dot Notation
Section titled “Bulk Updates with Dot Notation”// Bulk update nested fieldsawait userRepo.bulkUpdate([ { id: 'user-1', data: { 'profile.verified': true, 'settings.notifications': false, }, }, { id: 'user-2', data: { 'profile.verified': true, }, },]);Bulk Patch Convenience Alias
Section titled “Bulk Patch Convenience Alias”Use bulkPatch(...) when you want merge-style normalization for batch updates without manually
flattening nested objects. Like patch(), bulkPatch() always merges.
await userRepo.bulkPatch([ { id: 'user-1', data: { profile: { settings: { theme: 'dark', }, }, }, }, { id: 'user-2', data: { 'profile.settings.notifications': true, }, },]);Query Updates with Dot Notation
Section titled “Query Updates with Dot Notation”// Update nested fields for all matching documentsawait userRepo.query().where('role', '==', 'admin').update({ 'permissions.canDelete': true, 'permissions.canEdit': true,});
// Update deeply nested analyticsawait postRepo.query().where('published', '==', true).update({ 'analytics.impressions': 0, 'analytics.lastUpdated': new Date().toISOString(),});Transactions with Dot Notation
Section titled “Transactions with Dot Notation”updateInTransaction(tx, id, data, options?) supports dot notation directly.
patchInTransaction(tx, id, data, options?) is the always-merge convenience alias — merge is
implied, so its only option is { lastUpdateTime? } for
optimistic concurrency.
await userRepo.runInTransaction(async (tx, repo) => { // Read first only when your business logic needs current state const user = await repo.getInTransaction(tx, 'user-123');
if (!user) { throw new Error('User not found'); }
// Update nested fields directly await repo.updateInTransaction(tx, 'user-123', { 'settings.theme': 'dark', 'profile.lastLogin': new Date().toISOString(), });});FieldValue Sentinels
Section titled “FieldValue Sentinels”Dot-notation paths compose with Firestore FieldValue sentinels across every write surface. See
Per-Field Sentinel Approval for the full
sentinel model and per-field validation.
A sentinel only passes on a field whose write schema permits it. v3 defaults to
sentinelPolicy: 'strict', so a plain field accepts no sentinel — a withSchema repository
with no writeSchema overlay rejects every example below with a ValidationError. Declare the
sentinel each field may receive with the write combinators, then pass the overlay as writeSchema:
import { FieldValue } from 'firebase-admin/firestore';import { zArrayWrite, zNumberWrite, zSentinel, withDelete } from 'flintfire';import { z } from 'zod';
// Write overlay: each field declares which sentinel it accepts.const userWrite = userSchema.extend({ createdAt: z.union([z.string(), zSentinel('serverTimestamp')]), loginCount: zNumberWrite(), // number | increment tags: zArrayWrite(z.string()), // string[] | arrayUnion | arrayRemove deprecatedField: withDelete(z.string().optional()), // string | delete()});
const userRepo = FirestoreRepository.withSchema(db, 'users', userSchema, { writeSchema: userWrite,});With that overlay in place, sentinels are accepted with no cast:
// Create with server timestampawait userRepo.create({ name: 'Alice', createdAt: FieldValue.serverTimestamp(),});
// Atomic updatesawait userRepo.update('user-123', { loginCount: FieldValue.increment(1), tags: FieldValue.arrayUnion('beta-user'), deprecatedField: FieldValue.delete(),});
// Works in query updates and transactions tooawait userRepo .query() .where('role', '==', 'admin') .update({ tags: FieldValue.arrayRemove('legacy'), });FieldValue.delete() is rejected on create / bulkCreate / upsert even when the field permits
it — clear a field with update() or patch().
Type Safety & Validation
Section titled “Type Safety & Validation”Dot-notation is first-class in v3 — no as any required.
Typed field paths. The update input type reuses the Firestore Admin SDK’s UpdateData<T>, so
nested paths are generated from your model. Valid paths and leaf values type-check; typos, wrong
value types, and a non-writable id are compile errors:
await userRepo.update('user-123', { 'address.city': 'NYC' }); // ✓ typedawait userRepo.update('user-123', { 'address.city': 123 }); // ✗ compile error (city is a string)await userRepo.update('user-123', { 'addres.city': 'NYC' }); // ✗ compile error (typo)Runtime validation (schema repos). On a repository created with withSchema(...), each
dot-notation value is validated against its resolved leaf schema, and the dotted key is
persisted (never stripped). A value that violates the schema throws ValidationError, and a
dotted key that is not part of the schema throws — instead of silently doing nothing:
await userRepo.update('user-123', { 'address.city': 123 }); // throws ValidationErrorawait userRepo.update('user-123', { 'address.nope': 'x' }); // throws (unknown field path)Paths into a dynamic map field (z.record(...)) can’t be resolved to a leaf schema and are written
through as-is. Malformed paths (a..b, .a, a.) are rejected up front via
validateDotNotationPath.
Querying into a dynamic map. The typed query paths (FieldPaths<OmitId<S>>, from the stored
shape after synthetic-id removal, for where / orderBy / select) are generated from the
schema’s declared fields. Declared siblings on an intersection with Record<string, unknown>
(for example a name field beside a dynamic map — including direct-constructor models that also
declare synthetic id) are available as typed string paths. Arbitrary subkeys of a z.record(...)
map (or paths deeper than the type’s depth bound) are not included. To filter or order by a
dynamic map key, pass a FieldPath:
import { FieldPath } from 'firebase-admin/firestore';
await repo.query().where(new FieldPath('metadata', 'plan'), '==', 'pro').get();Create/set reject dotted keys. Firestore only interprets dots as field paths on update(); on
set()/add() a dotted key would create a field whose name contains a dot. So dot-notation keys
are a compile error on create/upsert, and are rejected at runtime if forced with a cast. Use a
nested object on create, or update() for field-path merges.
Important Notes
Section titled “Important Notes”1. Firestore Limitations
- Undefined values are automatically filtered out (Firestore doesn’t accept
undefined) - Use
nullif you need to explicitly clear a field value
// Undefined is filtered out, original value preservedawait userRepo.update('user-123', { 'address.city': undefined,});
// Use null to clear a fieldawait userRepo.update('user-123', { 'address.city': null,});2. Transaction Requirements
updateInTransaction() supports dot notation directly. Use getInTransaction() only when your
transaction logic needs the existing document state.
// Valid - read first only when needed by business logicawait repo.runInTransaction(async (tx, repo) => { const doc = await repo.getInTransaction(tx, 'doc-123'); if (!doc) throw new Error('Document not found'); await repo.updateInTransaction(tx, 'doc-123', { 'nested.field': 'value', });});3. Schema Validation with Sentinels
When using repositories created with withSchema(...), the default sentinelPolicy: 'strict'
restricts which sentinels a field may receive: declare fields with the write combinators to approve
sentinels per field. Opt into sentinelPolicy: 'permissive' (the opt-in, pre-v3 default) to instead
ignore any field assigned to a FieldValue sentinel during Zod validation while still validating
all other fields in the payload — see
Per-Field Sentinel Approval.
Dot-Notation Utilities
Section titled “Dot-Notation Utilities”The library exports the dot-notation helpers it uses internally, so you can build and inspect dot-notation payloads in your own code. Import them from the package root:
import { isDotNotation, hasDotNotationKeys, expandDotNotation, flattenToDotNotation, mergeDotNotationUpdate, validateDotNotationPath, getRootFields, getDotNotationDepth,} from 'flintfire';| Utility | Signature | Behavior |
|---|---|---|
isDotNotation(key) |
(key: string) => boolean |
true when the key contains a .. |
hasDotNotationKeys(obj) |
(obj: Record<string, any>) => boolean |
true when any key in the object uses dot notation. |
expandDotNotation(flatObj) |
<T = any>(flatObj: Record<string, any>) => T |
Expands flat dot-notation keys into a nested object. Throws if any segment is __proto__, prototype, or constructor. |
flattenToDotNotation(obj, prefix?) |
(obj: Record<string, any>, prefix?: string) => Record<string, any> |
Flattens a nested object into dot-notation keys. Only plain objects are flattened — arrays, Date instances, and class instances are left as-is. |
mergeDotNotationUpdate(existing, updates) |
(existing: Record<string, any>, updates: Record<string, any>) => Record<string, any> |
Merges a mixed regular/dot-notation update into existing data, skipping undefined values. Throws on a __proto__ / prototype / constructor segment. |
validateDotNotationPath(key) |
(key: string) => void |
Throws if the path is empty, starts or ends with a ., contains an empty segment, or uses a __proto__ / prototype / constructor segment (prototype-pollution guard). |
getRootFields(keys) |
(keys: string[]) => string[] |
Returns the unique top-level roots, e.g. ['address.city', 'address.zip', 'name'] → ['address', 'name']. |
getDotNotationDepth(key) |
(key: string) => number |
Number of path segments, e.g. 'address.city' → 2, 'name' → 1. |
// Expand flat keys into a nested objectexpandDotNotation({ 'address.city': 'LA', 'address.zip': '90001', name: 'John' });// => { address: { city: 'LA', zip: '90001' }, name: 'John' }
// Flatten a nested object into dot-notation keysflattenToDotNotation({ address: { city: 'LA', zip: '90001' }, name: 'John' });// => { 'address.city': 'LA', 'address.zip': '90001', name: 'John' }
// Guard a path before writingvalidateDotNotationPath('address..city'); // throws: Parts cannot be emptyUse Cases
Section titled “Use Cases”User Preferences
Update specific settings without replacing all preferences:
await userRepo.update('user-123', { 'preferences.emailNotifications': true, 'preferences.theme': 'dark',});Nested Configurations
Modify individual config values in complex objects:
await configRepo.update('app-config', { 'features.darkMode.enabled': true, 'features.darkMode.autoSwitch': true, 'features.analytics.trackingId': 'GA-123456',});Analytics Counters
Update nested counter fields:
await postRepo.update('post-123', { 'analytics.views': 150, 'analytics.likes': 42, 'analytics.shares': 8,});Status Updates
Update status in nested workflow objects:
await orderRepo.update('order-123', { 'workflow.payment.status': 'completed', 'workflow.payment.completedAt': new Date().toISOString(), 'workflow.fulfillment.status': 'pending',});Partial Address Updates
Update only changed address fields:
await userRepo.update('user-123', { 'shippingAddress.street': '456 New Street', 'shippingAddress.apt': '10B', // city, state, zipCode remain unchanged});