From Drizzle ORMSupported
Drizzle is the closest neighbour: both compile to SQL, both derive types from a schema object, neither tracks entities. The move is mostly mechanical.
Schema#
// Drizzle
import { pgTable, serial, text, boolean, timestamp } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: text('email').notNull().unique(),
active: boolean('active').default(true).notNull(),
});// zmdb
import type { HasDefault, PrimaryKey, Serial, Sql, Table, Unique } from '@zmdb/core/tags';
export interface User extends Table<'users'> {
id: number & Sql<'integer'> & Serial & PrimaryKey;
email: string & Sql<'text'> & Unique;
active: boolean & HasDefault;
}Differences that matter:
- It is a type, not a value. There is no
pgTablecall and nothing to construct —schemaOf<User>()produces the value the query compiler reads, at build time. - No dialect-specific import. One declaration supplies the shared shape; you pick the dialect when you build a compiler or repository. SingleStore tables additionally declare
ShardKey<…>or
Rowstore because distribution/storage cannot be inferred safely.
- Columns currently take no public name argument. With identity naming, the property key is also the SQL column name. A project can now configure
snake_case,snake_case_plural, or a custom
build-time strategy; the explicit-name tag remains tracked on Naming Strategy.
- Nullability is
| null, not.notNull()— the default is non-null, and TypeScript already has a way to say the other thing. Write(T & Tags) | null, tags inside. HasDefaultrather than.default(true): it says the column _has_ a default, not which one. The value goes in the migration, because a type cannot hold a runtime value. This is the one thing
Drizzle expresses that a declaration cannot.
Types#
| Drizzle | zmdb |
|---|---|
typeof users.$inferSelect | Entity<User> |
typeof users.$inferInsert | CreateDTO<User> |
| — | UpdateDTO<User> |
| — | WhereDTO<User> |
The zmdb column takes the declared interface, not typeof a value — the declaration is already the type, so there is nothing to read it back out of.
Queries#
Drizzle's db.select().from(users).where(eq(users.email, x)) becomes either a repository call or a compiler call:
import { trustedTable } from '@zmdb/sql';
import { postgres } from '@zmdb/postgres';
// repository — typed against the schema
await repo.findOne({ email: { eq: 'a@b.c' } });
// compiler — SQL text, no connection
createQueryCompiler(postgres).selectFrom(trustedTable('users')).where('email', '=', 'a@b.c').compile();Note the two operator vocabularies: the DTO uses eq / gte / in, the builder uses '=' / '>=' / 'in'. The DTO one is typed per column; the builder one is closer to the SQL.
Relational queries#
db.query.users.findMany({ with: { posts: true } }) becomes:
await repo.findAll({ populate: ['posts'] });Same shape of result, same one-query-per-relation strategy. See Loading Strategies.
Migrations#
drizzle-kit generate becomes a script calling snapshot() + diff() + emitUp(). The snapshot file plays the same role as Drizzle's meta/_journal.json + snapshot pair. See generate for the script and CLI Overview for what is missing.
For an existing Drizzle-managed database, start with schema-first adoption instead of translating the schema object blind: introspect into a staging directory, review the generated tags and warnings, commit a baseline snapshot, and run detectDrift() against a restored database in CI. The pull command packages that library workflow rather than defining a second one. It writes protected staging declarations under .zmdb/introspected, with --dry-run for review and --check for CI.
Validation#
Drop drizzle-zod. assert<CreateDTO<User>>(body) is generated from the same declaration by the transformer, so there is no second schema to keep in sync. See assert().
What you lose#
- arbitrary
ON CONFLICTpredicates — the typed common forms are covered by Upsert - arbitrary SQL update expressions — the closed atomic forms (
inc,dec,mul,not,concat,coalesce,proposed) are covered by Incrementing a value drizzle-kit studio's write controls — zmdb's Studio is deliberately read-only- the
pg/mysql/sqlitetype zoo: zmdb has ten column types, not sixty.Sql<'json'>and custom types cover most of the rest.
What you gain#
- one declaration instead of schema +
drizzle-zod+@ApiProperty - the shape of a JSON column reaching the validator, the DTOs and the OpenAPI document — Drizzle's
$type<T>()is a cast that stops at the type layer - an HTTP framework and a validator in the same type graph
- zero runtime dependencies
---
See also: Why zmdb · Schema Declaration · Tag Reference · Filters & Operators