Skip to content

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.

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'.

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().

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) }); // ok
await userRepo.update('u1', { loginCount: FieldValue.arrayUnion('x') }); // throws ValidationError
await userRepo.update('u1', { name: FieldValue.serverTimestamp() }); // throws ValidationError

sentinelPolicy 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 required
await userRepo.update('u1', { loginCount: FieldValue.increment(1) }); // no cast
await userRepo.update('u1', { tags: FieldValue.arrayUnion('x') }); // no cast

Everything else is enforced at runtime under 'strict':

  • ✅ Combinator native values / sentinels are accepted with no cast; create needs no id.
  • create rejects wrong scalar types at compile time (e.g. a string in a number field).
  • ⚠️ update is looser (Firestore’s PartialWithFieldValue): it catches wrong primitives but not, for example, a raw number written into a Date-typed field.
  • ⚠️ The sentinel kind is never compile-checked — Firestore’s WithFieldValue accepts any FieldValue on any field, so arrayUnion into a zNumberWrite() field compiles and is rejected only at runtime under 'strict'.

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 zod
export 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.