Storing a Timestamp, reading a millisecond number
Store Firestore Timestamps on write but read and work with milliseconds-since-epoch numbers in
application code.
A common pattern is to store a Firestore Timestamp but work with milliseconds-since-epoch
numbers in application code. Define a plain base schema as the read shape (so z.infer gives
a clean, shareable type), then .extend the temporal field with zDateWrite() for write
validation, and convert Timestamp -> number on read with createMillisTimestampConverter:
import { FieldValue } from 'firebase-admin/firestore';import { FirestoreRepository, zDateWrite, createMillisTimestampConverter,} from '@reggieofarrell/firestore-orm';import { z } from 'zod';
// Base schema = the read shape. `happenedAt` reads as an ms number. This is zod-only, so its// inferred type is a clean API-contract type you can share (e.g. with a front-end). The required// top-level `id: z.string()` is mandatory — the factory throws at construction without it.const eventBase = z.object({ id: z.string(), name: z.string().min(1), happenedAt: z.number(), // ms since epoch on read});type EventDoc = z.infer<typeof eventBase>;
// Write overlay: swap the temporal field to accept a Date or serverTimestamp() (a raw number is// rejected). Only write validation widens — the read type stays the plain `EventDoc`.const eventWrite = eventBase.extend({ happenedAt: zDateWrite(),});
// The read converter recursively maps stored Timestamps to ms numbers and returns data WITHOUT// `id` (the repository overlays the document id afterward); toFirestore is a pass-through.const converter = createMillisTimestampConverter<EventDoc>();Build the repository with the curried form so write inputs are inferred from eventWrite — a
Date / serverTimestamp() is then accepted with no cast, while reads still return EventDoc
(happenedAt: number):
const events = FirestoreRepository.withSchema<EventDoc>()(db, 'events', eventWrite, converter);
await events.create({ name: 'launch', happenedAt: FieldValue.serverTimestamp() });await events.update(id, { happenedAt: new Date() }); // no castconst ev = await events.getById(id); // ev.happenedAt is a number (ms)The direct form is equivalent at runtime but types write inputs by the read type, so a Date
needs a cast (a FieldValue such as serverTimestamp() does not — every field already accepts
one):
const events = FirestoreRepository.withSchema<EventDoc>(db, 'events', eventWrite, converter);
await events.create({ name: 'launch', happenedAt: FieldValue.serverTimestamp() });await events.update(id, { happenedAt: new Date() as unknown as number }); // cast requiredPass a fields array to convert only specific top-level fields (each recursively) and leave every
other Timestamp intact:
createMillisTimestampConverter<EventDoc>(['happenedAt']);Notes:
- Write a
DateorserverTimestamp(), not a rawnumber— the Admin SDK stores both as aTimestampon every write path (includingupdate()). AFirestoreDataConverter.toFirestoreis not invoked on any update path (update,patch,bulkUpdate,bulkPatch,query().update,updateInTransaction,patchInTransaction) — it runs only on create/set paths — so the converter deliberately does no write-side conversion. - Prefer the curried
withSchema<EventDoc>()(...): it infers the write type fromeventWrite, so aDateis accepted oncreate/updatewith no cast, while reads stay typed asEventDoc. The direct form types write inputs by the read type (happenedAt: number), sozDateWrite()only widens runtime validation there — aFieldValuesuch asserverTimestamp()is still accepted without a cast (WithFieldValuewidens every field to| FieldValue), but aDateneeds one. See Per-Field Sentinel Approval for the full contract.
Converter helpers
Section titled “Converter helpers”The main entry also exports the primitives the converter is built from:
| Export | Purpose |
|---|---|
createMillisTimestampConverter<T>(fields?) |
Build a FirestoreDataConverter (recursive read conversion, pass-through write) |
convertTimestampsToMillis<T>(data) |
Recursively convert every Timestamp in a value to an ms number (returns a copy) |
convertTimestampToMillis(ts) |
Convert a single Timestamp to an ms number (throws if not a Timestamp) |
convertMillisToTimestamp(ms) |
Convert an ms number to a Timestamp |
convertTimestampsToMillis uses a structural toMillis duck-check and never references
firebase-admin, so it is safe to reuse in shared/browser code; non-Timestamp value types (a
VectorValue, GeoPoint, or DocumentReference) are left untouched.
Working in number on both sides
Section titled “Working in number on both sides”The converter only handles the read direction (writes go through native Date /
serverTimestamp()). If you want application code to author numbers on write too, convert in a
beforeCreate / beforeUpdate hook with convertMillisToTimestamp (before-hooks run on the
standard create/update write paths, ahead of validation) and widen the write schema to accept a
Timestamp at that field.
Under the hood
Section titled “Under the hood”createMillisTimestampConverter() is equivalent to a hand-written converter whose fromFirestore
maps stored Timestamps to ms and whose toFirestore is a pass-through:
import { Timestamp, FirestoreDataConverter } from 'firebase-admin/firestore';
const eventConverter: FirestoreDataConverter<EventDoc> = { toFirestore: data => data as FirebaseFirestore.DocumentData, // pass-through fromFirestore: snap => { const data = snap.data(); return { name: data.name, happenedAt: (data.happenedAt as Timestamp).toMillis(), } as EventDoc; // `id` is overlaid by the repository, so it is omitted here },};