firesmith
Thin typed wrapper over Firestore. It types and coerces, it does not validate: the schema is used for type inference only and is never executed, so there is no runtime validation. Underneath it is plain Firestore; collection, document and query handles expose .ref to drop to the raw SDK.
Setup
# server
npm install firesmith firebase-admin
# web
npm install firesmith firebase
Three entrypoints:
firesmith: neutral. Schema definition, sentinels, types, FiresmithError. Imports no Firebase, safe in shared modules and frontend bundles.
firesmith/admin: createDatabase(getFirestore()) over firebase-admin, plus typed ref helpers. Re-exports the whole neutral core.
firesmith/web: the same over the modular firebase SDK.
Quick start
import { collection, increment } from "firesmith";
// or "firesmith/web"
import { createDatabase } from "firesmith/admin";
import { getFirestore } from "firebase-admin/firestore";
import { z } from "zod";
// Plain, unbound definition, define once in a shared module importing only "firesmith"
const posts = collection(
"posts",
z.object({ title: z.string(), likes: z.number(), createdAt: z.date() }),
);
const db = createDatabase(getFirestore());
await db
.collection(posts)
.set("hello", { title: "Hello", likes: 0, createdAt: new Date() });
// Doc | null, Doc = T & { id; meta: { path } }
const post = await db.collection(posts).get("hello");
await db.collection(posts).update("hello", { likes: increment(1) });
const popular = await db
.collection(posts)
.where("likes", ">=", 10)
.orderBy("likes", "desc")
.limit(10)
.get();
The schema is any Standard Schema validator (Zod, Valibot, ArkType). It describes one document's fields, excluding id and meta.
Core rules
id and meta come from the path. Never declare either in a schema (compile error). Reads merge in id and meta.path, the full document path; writes strip both. add(data) returns the generated id.
- Missing reads are
null. get() returns Doc<S> | null; there is no .exists snapshot to check (use .doc(id).exists() for a boolean).
- Values coerce at the boundary. Write a
Date, read a Date (stored as Timestamp); write a Uint8Array, read a Uint8Array (stored as SDK bytes). Deep through maps and arrays.
- Sentinels are
firesmith's own. Import serverTimestamp, increment, arrayUnion, arrayRemove, deleteField from firesmith, never FieldValue from an SDK. Each is type-constrained to the fields it fits (increment on numbers, deleteField on optional fields only).
- Queries are typed to the schema.
where / orderBy take typed dotted paths into nested maps ("customer.address.city"); a misspelt field or wrong value type is a compile error. Target the id with documentId() from firesmith; in a collection-group query its cursor value is doc.meta.path.
- Dotted-path keys work in
update only. update("o1", { "totals.items": increment(1) }) touches one nested field. Merge set accepts recursively partial nested objects with merge: true or mergeFields, including nested optional-field deletions. Whole-map update values must be complete replacements.
- Transaction and batch writes take no
await. Reads in a transaction are async; writes return void and buffer until commit. Batches are write-only (no get, no add) and apply atomically on .commit().
- No runtime validation. A document drifted from its schema comes back typed as valid. Where drift matters, run the schema on the result yourself.
- SDK semantics win.
firesmith never wraps Firestore, network, or permission errors; FiresmithError covers only firesmith's own failures. Anything firesmith does not wrap is reachable via .ref on collection, document and query handles. Transactions and batches have no escape hatch.
Common operations
const col = db.collection(posts);
// Reads
// Doc | null
await col.get("hello");
// boolean
await col.doc("hello").exists();
// Writes, also on col.doc("hello")
await col.set("hello", { title: "Hi", likes: 0, createdAt: new Date() });
await col.set("hello", { likes: 1 }, { merge: true });
await col.update("hello", { likes: increment(1) });
// returns the new id
const id = await col.add({ title: "New", likes: 0, createdAt: new Date() });
await col.delete(id);
// Queries, immutable builder, .get() alias .list()
await col.where("likes", ">=", 10).orderBy("likes", "desc").limit(5).get();
await col.count();
// 0 over empty match
await col.sum("likes");
// null over empty match
await col.average("likes");
// returns unsubscribe fn
const unsub = col.onSnapshot((docs) => {});
// Subcollection, same def reusable at any depth
db.collection(posts).doc("hello").collection(comments);
// Collection group, every collection with that name
await db.collectionGroup(comments).orderBy("createdAt").get();
// Transaction, reads async, writes sync
await db.runTransaction(async (tx) => {
const post = await tx.collection(posts).get("hello");
if (post) tx.collection(posts).update("hello", { likes: post.likes + 1 });
});
// Batch, write-only, atomic on commit
const batch = db.batch();
batch.collection(posts).update("hello", { likes: increment(1) });
await batch.commit();
Full reference
See REFERENCE.md for queries and cursors, sentinels and nested updates, live updates, transactions and batches in detail, raw fields and full-precision timestamps, bytes, neutral value types, the .ref escape hatch, and gotchas.
1---2name: firesmith3description: Read and write Firestore through firesmith, a schema-first typed wrapper over both Firestore SDKs (firebase-admin and firebase web) from one Standard Schema definition. Use when a project imports "firesmith", when writing Firestore data-access code in such a project, or when the user mentions firesmith, typed Firestore collections, or schema-typed Firestore queries.4---56# firesmith78Thin typed wrapper over Firestore. It **types and coerces, it does not validate**: the schema is used for type inference only and is never executed, so there is no runtime validation. Underneath it is plain Firestore; collection, document and query handles expose `.ref` to drop to the raw SDK.910## Setup1112```sh13# server14npm install firesmith firebase-admin15# web16npm install firesmith firebase17```1819Three entrypoints:2021- `firesmith`: neutral. Schema definition, sentinels, types, `FiresmithError`. Imports no Firebase, safe in shared modules and frontend bundles.22- `firesmith/admin`: `createDatabase(getFirestore())` over `firebase-admin`, plus typed ref helpers. Re-exports the whole neutral core.23- `firesmith/web`: the same over the modular `firebase` SDK.2425## Quick start2627```ts28import { collection, increment } from "firesmith";29// or "firesmith/web"30import { createDatabase } from "firesmith/admin";31import { getFirestore } from "firebase-admin/firestore";32import { z } from "zod";3334// Plain, unbound definition, define once in a shared module importing only "firesmith"35const posts = collection(36 "posts",37 z.object({ title: z.string(), likes: z.number(), createdAt: z.date() }),38);3940const db = createDatabase(getFirestore());4142await db43 .collection(posts)44 .set("hello", { title: "Hello", likes: 0, createdAt: new Date() });45// Doc | null, Doc = T & { id; meta: { path } }46const post = await db.collection(posts).get("hello");47await db.collection(posts).update("hello", { likes: increment(1) });48const popular = await db49 .collection(posts)50 .where("likes", ">=", 10)51 .orderBy("likes", "desc")52 .limit(10)53 .get();54```5556The schema is any [Standard Schema](https://standardschema.dev) validator (Zod, Valibot, ArkType). It describes one document's fields, excluding `id` and `meta`.5758## Core rules59601. **`id` and `meta` come from the path.** Never declare either in a schema (compile error). Reads merge in `id` and `meta.path`, the full document path; writes strip both. `add(data)` returns the generated id.612. **Missing reads are `null`.** `get()` returns `Doc<S> | null`; there is no `.exists` snapshot to check (use `.doc(id).exists()` for a boolean).623. **Values coerce at the boundary.** Write a `Date`, read a `Date` (stored as `Timestamp`); write a `Uint8Array`, read a `Uint8Array` (stored as SDK bytes). Deep through maps and arrays.634. **Sentinels are `firesmith`'s own.** Import `serverTimestamp`, `increment`, `arrayUnion`, `arrayRemove`, `deleteField` from `firesmith`, never `FieldValue` from an SDK. Each is type-constrained to the fields it fits (`increment` on numbers, `deleteField` on optional fields only).645. **Queries are typed to the schema.** `where` / `orderBy` take typed dotted paths into nested maps (`"customer.address.city"`); a misspelt field or wrong value type is a compile error. Target the id with `documentId()` from `firesmith`; in a collection-group query its cursor value is `doc.meta.path`.656. **Dotted-path keys work in `update` only.** `update("o1", { "totals.items": increment(1) })` touches one nested field. Merge `set` accepts recursively partial nested objects with `merge: true` or `mergeFields`, including nested optional-field deletions. Whole-map `update` values must be complete replacements.667. **Transaction and batch writes take no `await`.** Reads in a transaction are async; writes return `void` and buffer until commit. Batches are write-only (no `get`, no `add`) and apply atomically on `.commit()`.678. **No runtime validation.** A document drifted from its schema comes back typed as valid. Where drift matters, run the schema on the result yourself.689. **SDK semantics win.** `firesmith` never wraps Firestore, network, or permission errors; `FiresmithError` covers only `firesmith`'s own failures. Anything `firesmith` does not wrap is reachable via `.ref` on collection, document and query handles. Transactions and batches have no escape hatch.6970## Common operations7172```ts73const col = db.collection(posts);7475// Reads76// Doc | null77await col.get("hello");78// boolean79await col.doc("hello").exists();8081// Writes, also on col.doc("hello")82await col.set("hello", { title: "Hi", likes: 0, createdAt: new Date() });83await col.set("hello", { likes: 1 }, { merge: true });84await col.update("hello", { likes: increment(1) });85// returns the new id86const id = await col.add({ title: "New", likes: 0, createdAt: new Date() });87await col.delete(id);8889// Queries, immutable builder, .get() alias .list()90await col.where("likes", ">=", 10).orderBy("likes", "desc").limit(5).get();91await col.count();92// 0 over empty match93await col.sum("likes");94// null over empty match95await col.average("likes");96// returns unsubscribe fn97const unsub = col.onSnapshot((docs) => {});9899// Subcollection, same def reusable at any depth100db.collection(posts).doc("hello").collection(comments);101102// Collection group, every collection with that name103await db.collectionGroup(comments).orderBy("createdAt").get();104105// Transaction, reads async, writes sync106await db.runTransaction(async (tx) => {107 const post = await tx.collection(posts).get("hello");108 if (post) tx.collection(posts).update("hello", { likes: post.likes + 1 });109});110111// Batch, write-only, atomic on commit112const batch = db.batch();113batch.collection(posts).update("hello", { likes: increment(1) });114await batch.commit();115```116117## Full reference118119See [REFERENCE.md](REFERENCE.md) for queries and cursors, sentinels and nested updates, live updates, transactions and batches in detail, raw fields and full-precision timestamps, bytes, neutral value types, the `.ref` escape hatch, and gotchas.