Quick StartSupported
This guide takes you from an empty project to a validated, type-safe data layer in a few minutes. By the end you will have defined a schema, derived its types, run CRUD through a repository, and issued a typed query. Continue with the blog API tutorial to put the same data layer behind HTTP, then generate its client contract.
zmdb targets Node.js 26+, TypeScript 7+, and is ESM-only. The default product includes the SQLite provider. You own its connection and pass the selected driver into the repository; other database providers are explicit selections.
1. Install#
yarn add @zmdb/core@1.0.0-beta.2@zmdb/core includes SQLite and exposes its driver through @zmdb/core/sqlite. (Prefer granular installs? See Installation.) Then wire the transformer once — see AOT setup. It is not an optimisation you can skip: schemaOf<T>() and the validators read a type argument, which does not exist at runtime, so an untransformed build throws rather than quietly checking nothing.
2. Declare your table once#
A table is a TypeScript type. That declaration is the single source of truth, and everything else derives from it.
import type { HasDefault, Min, Pattern, PrimaryKey, References, Serial, Sql, Table } from '@zmdb/core';
export interface User extends Table<'users'> {
id: number & Sql<'integer'> & Serial & PrimaryKey;
email: string & Sql<'text'> & Pattern<'^[^@]+@[^@]+\\.[^@]+$'>;
role: ('admin' | 'user') & HasDefault;
createdAt: Date & Sql<'timestamp'> & HasDefault;
}
export interface Order extends Table<'orders'> {
id: number & Sql<'integer'> & Serial & PrimaryKey;
userId: number & Sql<'integer'> & References<'users.id'>;
total: number & Sql<'numeric'> & Min<0>;
}Each property is its app type intersected with tags. The app type is what your code sees; the tags say what TypeScript has no syntax for. Tags are phantom unique symbol slots, so they erase completely — this file compiles to no JavaScript at all.
The build reads the TypeScript declaration. No separate builder declaration or global schema registry is required.
3. Types derive automatically#
import type { CreateDTO, Entity, UpdateDTO } from '@zmdb/core';
type Row = Entity<User>;
// { id: number; email: string; role: 'admin' | 'user'; createdAt: Date }
type CreateUser = CreateDTO<User>;
// { email: string; role?: 'admin' | 'user'; createdAt?: Date } ← id absent (Serial); HasDefault → optional
type UpdateUser = UpdateDTO<User>; // Partial<CreateUser>Change a column and every derived type updates. Any call site that no longer satisfies them fails to compile — that compile error is the anti-drift guarantee. See Type derivation.
Serial removes id from the create type rather than making it optional: there is no value you could usefully pass for a column the database generates.
4. CRUD through a repository#
A repository binds your schema to a driver. The fastest way is the defineRepository helper (no subclass, no hand-written driver) with the included @zmdb/core/sqlite adapter for node:sqlite:
import { DatabaseSync } from 'node:sqlite';
import { defineRepository, schemaOf } from '@zmdb/core';
import { sqliteDriver } from '@zmdb/core/sqlite';
const db = new DatabaseSync('app.db'); // or ':memory:'
const users = defineRepository(schemaOf<User>(), sqliteDriver(db));
const u = await users.create({ email: 'a@b.com' }); // validated vs CreateDTO<S>
const one = await users.findById(u.id); // Entity<S> | undefined
const admins = await users.find({ role: 'admin' }); // typed WhereDTO<S>
const page = await users.list({ page: { limit: 20 } }); // ListResult<Entity<S>>
const updated = await users.update(u.id, { role: 'admin' }); // UpdatePatch<S>; plain values validate as UpdateDTO<S>
const gone = await users.delete(u.id); // booleanPrefer a class? Subclassing works identically:
import { BaseRepository } from '@zmdb/core/orm';
const userSchema = schemaOf<User>();
class UserRepository extends BaseRepository<User> {
static readonly schema = userSchema;
}
const users = new UserRepository(sqliteDriver(db));schemaOf<T>() is a compile-time call — the answer is a function of a type argument, and type arguments do not exist at runtime. The transformer replaces it with a frozen object literal. An untransformed build throws a message saying exactly that; it does not hand back an empty schema. Wire up the plugin or the codegen CLI.
Install @zmdb/postgres and pg, then use postgresDriver(pool) for PostgreSQL. The driver carries the same frozen dialect object used by compilation, migrations, and introspection. See Drivers.
Rows you read back are plain, inert objects. Mutating user.email = 'x' persists nothing — writes only happen through create/update/delete. This is deliberate; see Why fetched rows are inert.
5. Query your data (typed)#
import { applyOrderBy, buildListResult, compileWhere } from '@zmdb/core/schema';
let qb = users.query.selectFrom(userSchema);
qb = compileWhere(qb, { role: 'admin', createdAt: { gte: since } });
qb = applyOrderBy(qb, [{ column: 'createdAt', dir: 'desc' }]);
const rows = await driver.execute(qb.limit(21).compile());
const page = buildListResult(rows, { limit: 20 }); // { items, hasMore }SELECT * FROM "users"
WHERE "role" = $1 AND "createdAt" >= $2
ORDER BY "createdAt" DESC
LIMIT 21The filter, ordering and pagination are all typed against User. See Filters, Ordering & pagination and the Read/Query DTOs.
6. Atomic writes with transactions#
import { createTransactionalDb } from '@zmdb/core/orm';
const db = createTransactionalDb(connection);
await db.transaction(async tx => {
// withTransaction re-binds a repository onto the transaction's connection
const user = await users.withTransaction(tx).create({ email: 'a@b.com' });
const order = await orders.withTransaction(tx).create({ userId: user.id, total: 42 });
// throw here → ROLLBACK; clean return → COMMIT
});7. Validate at the boundary#
import { assert, type CreateDTO } from '@zmdb/core';
// In an HTTP handler: validate the inbound body against the derived Create DTO.
const payload = assert<CreateDTO<User>>(await req.json());
const user = await users.create(payload);Where to go next#
- Blog API → Generated HTTP client → Client applications
- One server journey — generate the migration, serve validated HTTP, and explicitly add a SQLite-backed worker to the same application
- Schema declaration, the tag reference and Column types
- Relations and typed populate/join results
- Migrations — diffed from the schema
- Validators and JSON / Ser-De
- Anti-patterns — what zmdb deliberately does _not_ do, and why