Skip to content

Read Converters

FlintFire supports custom read deserialization (e.g. Timestamp -> number / Date) through an optional readConverter. This page is the canonical reference for the converter contract — other guides link here rather than restating it.

A readConverter is just the fromFirestore half of a converter — a (snapshot) => T mapper (the ReadConverter<T> type). The repository builds the full FirestoreDataConverter internally (your mapper plus a pass-through toFirestore) and attaches it to the read ref only, so fromFirestore runs on every read while writes go through a raw ref — a toFirestore is never even expressible, let alone invoked.

This removes a long-standing footgun: the Admin SDK already skipped toFirestore on update(), so relying on it was unreliable. For write-time normalization, use a before* hook (hooks run before validation on all write paths) — see Lifecycle Hooks.

import { z } from 'zod';
import { Timestamp } from 'firebase-admin/firestore';
import { FirestoreRepository, ReadConverter } from 'flintfire';
// The READ model. `createdAt` is declared as a Date here because that is what the converter below
// produces — the read schema must describe the converted shape, not the stored one.
const userSchema = z.object({
name: z.string(),
email: z.email(),
createdAt: z.date(),
});
type User = z.infer<typeof userSchema>;
// Runs on every read: map the stored Timestamp to the Date the read model declares. Return data
// WITHOUT `id` — the repository overlays the document id after the mapper returns.
const userReadConverter: ReadConverter<User> = snapshot => {
const data = snapshot.data();
return { ...data, createdAt: (data.createdAt as Timestamp).toDate() } as User;
};
// The at-rest shape query field paths derive from — `createdAt` is stored as a Timestamp, not the
// Date the read model exposes. Required whenever a readConverter restructures fields.
const userStoredSchema = userSchema.extend({ createdAt: z.instanceof(Timestamp) });
// Write a Date/serverTimestamp() (stored as a Timestamp on every write path); read back a Date.
const userRepo = FirestoreRepository.withSchema(db, 'users', userSchema, {
storedSchema: userStoredSchema,
readConverter: userReadConverter,
});

When a readConverter is supplied, storedSchema is required. The converter changes the read shape (createdAt reads as a Date, but is stored as a Timestamp), so query field paths — which must reflect the at-rest shape S — need an explicit stored schema to derive from. Without a converter, the stored shape defaults to the read shape.

Because the mapper receives only the stored document body, it must return data without an id field; the repository reads the snapshot’s document id and overlays it onto the result afterward. This is why reads resolve to FirestoreDocument<T> (Omit<T, 'id'> & { readonly id: ID }) even though the mapper never sets id itself — see Document Identity. A raw snapshot from a trigger cloud function is not converter-applied and has no id; use fromSnapshot to reconstruct the read shape there.

For the common Timestamp -> number case, the built-in createMillisTimestampConverter returns exactly this mapper (recursive read conversion), ready to pass as readConverter.

Converter behavior is instance-local by design:

  • Parent repositories and subcollections do not share converters automatically.
  • Pass a converter explicitly via subcollection(..., { readConverter }) for each subcollection that needs converter behavior — see Subcollections.

getMany(ids, { fieldMask }) (and getManyInTransaction) apply the mask before fromFirestore runs. The converter therefore receives the projected document — not the full stored shape. A converter that dereferences a field the mask omitted throws a raw TypeError:

// Converter assumes address is always present
const readConverter: ReadConverter<User> = snapshot => {
const data = snapshot.data();
return { ...data, label: `${data.name} (${data.address.city})` } as User;
};
// Mask omits address → data.address is undefined → TypeError at data.address.city
await userRepo.getMany(['u1'], { fieldMask: ['name'] });

Guard: omit the mask, widen it to cover every field the converter reads, or make the converter defensive (data.address?.city). The library cannot know which fields a user converter touches, so this is documented rather than suppressed.

Because a readConverter runs on every read, it is also the seam for coercing documents written under an older schema into the current shape — without a data migration. See Schema Evolution for that pattern. The ReadConverter<T> type is listed under Exported Types.