Per-Field Sentinel Approval
Opt into sentinelPolicy: 'strict' and per-field write combinators so each field accepts only its
declared type or an explicitly approved FieldValue sentinel.
Why per-field approval
Section titled “Why per-field approval”By default, validation accepts any FieldValue sentinel on any field
(sentinelPolicy: 'permissive'). That means a FieldValue.increment() written into a z.string()
field passes validation. To tighten this so a write must be either the field’s declared type or
a specific approved sentinel, declare each field with a write combinator and opt into
sentinelPolicy: 'strict'.
Write combinators
Section titled “Write combinators”| Combinator | Field accepts |
|---|---|
zNumberWrite() |
number or FieldValue.increment() |
zArrayWrite(elem) |
elem[] or FieldValue.arrayUnion() / arrayRemove() |
zDateWrite() |
Date or FieldValue.serverTimestamp() |
withDelete(schema) |
the wrapped type or FieldValue.delete() |
zSentinel(...kinds) |
a sentinel of one of the named kinds (compose with z.union) |
Each combinator accepts { allowDelete: true } to additionally permit FieldValue.delete().
Enabling strict mode
Section titled “Enabling strict mode”Declare each field with a combinator (or a plain type, which then accepts no sentinel under
strict), then pass { sentinelPolicy: 'strict' } as the factory options. Every schema still needs a
required top-level id: z.string() — the factory throws at construction otherwise.
import { FirestoreRepository, zNumberWrite, zArrayWrite, zSentinel,} from '@reggieofarrell/firestore-orm';import { FieldValue } from 'firebase-admin/firestore';import { z } from 'zod';
const userSchema = z.object({ id: z.string(), // required top-level id name: z.string().min(1), // plain -> no sentinel allowed under 'strict' loginCount: zNumberWrite(), // number | increment tags: zArrayWrite(z.string()), // string[] | arrayUnion | arrayRemove updatedAt: z.union([z.string(), zSentinel('serverTimestamp')]), // string | serverTimestamp});
// `User` is your clean read/contract type (no sentinels) — see the sharing section below.const userRepo = FirestoreRepository.withSchema<User>( db, 'users', userSchema, undefined, // converter (unchanged position) { sentinelPolicy: 'strict' },);
await userRepo.update('u1', { loginCount: FieldValue.increment(1) }); // okawait userRepo.update('u1', { loginCount: FieldValue.arrayUnion('x') }); // throws ValidationErrorawait userRepo.update('u1', { name: FieldValue.serverTimestamp() }); // throws ValidationErrorsentinelPolicy defaults to 'permissive' and is fully backwards compatible; 'strict' disables
the permissive escape hatch so only combinator-declared sentinels pass, and it is the mode that
actually enforces which sentinel kind each field accepts. The combinators are also useful in
'permissive' mode for documentation, but permissive still accepts any sentinel on any field — only
'strict' enforces them.
Cast-free combinator writes (curried form)
Section titled “Cast-free combinator writes (curried form)”Optionally get combinator types on create / update inputs. TypeScript can’t infer a second type
argument once you specify the read type, so withSchema has a curried opt-in form —
withSchema<Read>()(db, collection, schema, …) — where the first call fixes the read type Read
and the schema’s write type W = z.infer<typeof schema> is then inferred. With it, combinator
fields accept their native values / sentinels with no cast, while reads stay typed as Read:
const userRepo = FirestoreRepository.withSchema<User>()(db, 'users', userSchema, undefined, { sentinelPolicy: 'strict',});
await userRepo.create({ name: 'Ada', loginCount: 0, tags: [] }); // no id requiredawait userRepo.update('u1', { loginCount: FieldValue.increment(1) }); // no castawait userRepo.update('u1', { tags: FieldValue.arrayUnion('x') }); // no castWhat the write types catch (and don’t)
Section titled “What the write types catch (and don’t)”Everything else is enforced at runtime under 'strict':
- ✅ Combinator native values / sentinels are accepted with no cast;
createneeds noid. - ✅
createrejects wrong scalar types at compile time (e.g. a string in a number field). - ⚠️
updateis looser (Firestore’sPartialWithFieldValue): it catches wrong primitives but not, for example, a raw number written into aDate-typed field. - ⚠️ The sentinel kind is never compile-checked — Firestore’s
WithFieldValueaccepts anyFieldValueon any field, soarrayUnioninto azNumberWrite()field compiles and is rejected only at runtime under'strict'.
Direct form and id handling
Section titled “Direct form and id handling”The plain direct form — withSchema<User>(db, 'users', schema, …) — is unchanged and types writes
by the read type User (so a combinator value such as a Date/sentinel needs a cast). Use the
curried form when you want cast-free combinator writes.
The curry affects only these write value types — id handling is identical in both forms: a
required id in the schema, never required on write inputs, and stripped from every write payload
(see Schema Validation).
subcollection has the same curried opt-in form —
repo.subcollection<Read>()(parentId, name, schema, …) — with identical inference; its direct form
stays read-typed. (Converters are not inherited from the parent repo, and any schema you pass must
also include a required id.)
Sharing schema-derived types with a front-end
Section titled “Sharing schema-derived types with a front-end”withSchema<U> takes the read type U as an explicit generic, decoupled from the runtime schema.
So keep a plain base schema in shared code as the single source of truth for your API-contract
types, and apply combinators in a thin server-side overlay — the combinators (and
firebase-admin) never reach shared/browser code.
// shared/user.schema.ts — importable anywhere; depends only on zodexport const userBase = z.object({ id: z.string(), name: z.string().min(1), loginCount: z.number().int(), tags: z.array(z.string()),});export type User = z.infer<typeof userBase>; // clean contract type: no sentinels
// server/user.repo.ts — combinators live here only. Curried form so writes are inferred.import { zNumberWrite, zArrayWrite } from '@reggieofarrell/firestore-orm';const userWrite = userBase.extend({ loginCount: zNumberWrite(), tags: zArrayWrite(z.string()),});const userRepo = FirestoreRepository.withSchema<User>()(db, 'users', userWrite, undefined, { sentinelPolicy: 'strict',});
// Reads return the plain `User` (loginCount: number); writes accept the combinator types with no// cast, and `create` does not require `id`:await userRepo.create({ name: 'Ada', loginCount: 0, tags: [] });await userRepo.update('u1', { loginCount: FieldValue.increment(1) });Because userWrite extends userBase, it inherits the required top-level id, so both the shared
contract type and the server-side write schema satisfy the factory’s id requirement.