TypeScript Apache-2.0

better-drizzle

ORM, but better

A

almeidazs

Dernière activité 28 sept. 2026
almeidazs/better-drizzle

343

étoiles

6

forks

5

issues ouvertes

betterbetter-drizzledatabasedrizzledrizzle-ormmysqlneonnodejsormpostgresqlsqlitetypescript

Ce README est souvent en anglais.

better-drizzle


Drizzle ORM, but better 🚀

npm version zero dependencies license

Type-safe repository helpers for Drizzle ORM.

Keep Drizzle's type-safety. Drop the query glue you rewrite in every service.

Sponsors

Neon

Sponsored by Neon

Neon is the serverless Postgres platform built for modern developer workflows.

The whole idea

const client = better(drizzle({ client: pool, relations }));

const users = await client.users.findMany({
	where: { posts: { some: { published: true } } },
	include: {
		posts: { where: { published: true }, take: 3 },
		_count: { select: { posts: true } },
	},
});

A nested relation filter, three posts per user, and a relation count - typed end to end from your Drizzle schema and defineRelations(...) config. One query per relation node, never one per row.

No codegen. No client process. No new schema language. It is still your Drizzle client underneath, and you can drop back to it at any line.

npm install better-drizzle drizzle-orm@1.0.0-rc.4

Important

better-drizzle supports only Drizzle ORM 1.x (drizzle-orm >=1.0.0-rc.4 <1.0.0-rc.5; later release candidates are not supported yet) and its defineRelations(...) API. drizzle-orm 0.x is not supported - projects on 0.x must stay on better-drizzle 0.2.x. Install drizzle-orm with the explicit version: until Drizzle 1.0 is tagged latest, a plain install resolves to 0.x. See upgrading.

Setup

Tables live in your schema, relations are declared with Drizzle's defineRelations, and the Drizzle instance receives them. better() reads tables and relations from that instance.

// schema.ts
import { integer, pgTable, text } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
	id: integer().primaryKey(),
	email: text().notNull(),
});

export const posts = pgTable('posts', {
	id: integer().primaryKey(),
	userId: integer('user_id').notNull().references(() => users.id),
	title: text().notNull(),
});

// relations.ts
import { defineRelations } from 'drizzle-orm';
import * as schema from './schema';

export const relations = defineRelations(schema, (r) => ({
	users: { posts: r.many.posts() },
	posts: { author: r.one.users({ from: r.posts.userId, to: r.users.id }) },
}));

// db.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { better } from 'better-drizzle';
import { relations } from './relations';

export const client = better(drizzle({ connection: process.env.DATABASE_URL!, relations }));

With no relations, pass defineRelations(schema) without a callback.

What you stop writing

Raw Drizzle better-drizzle
Point lookups db.select().from().where(eq(...)) + unwrap findUnique({ where: { email } })
Relation loading manual joins, or db.query config include / select, payload inferred
Nested relation filters hand-built exists subqueries some / every / none / is
Pagination rebuild metadata and cursors every time paginate() / cursor() → { data, pagination }
Not-found handling check for undefined everywhere nullable result or .throw()
Cross-cutting concerns sprinkled through call sites hooks and plugins
Timestamps / soft delete repeated in every write official plugins

Relations you write, not assemble

Describe the link. It resolves the rows and runs the writes in one transaction.

await client.posts.create({
	data: {
		title: 'Hello',
		author: { connect: { email: 'alice@example.com' } },
	},
});

connect, disconnect, and set work on create, update, and both branches of upsert.

Many-to-many is declared once in defineRelations with .through(), and after that you never name the junction in a query:

// relations.ts
users: {
	groups: r.many.groups({
		from: r.users.id.through(r.memberships.userId),
		to: r.groups.id.through(r.memberships.groupId),
	}),
},
const users = await client.users.findMany({
	include: { groups: true },
});

Pagination that returns its own metadata

const page = await client.users.paginate({
	limit: 20,
	skip: 40,
	orderBy: [{ id: 'asc' }],
	where: { active: true },
});

page.pagination carries total, pageCount, hasNext, and hasPrevious. Use cursor() instead for feed-style navigation and you get nextCursor and previousCursor computed for you.

orderBy accepts a field map or an array. Specify { direction, nulls } when NULL placement matters: orderBy: { lastSeenAt: { direction: 'desc', nulls: 'last' } }.

Not-found, handled honestly

Operations that can legitimately match nothing say so in the type - and let you opt into throwing when it is genuinely exceptional.

const user = await client.users.findUnique({ where: { id } });
//    ^? User | null

const user = await client.users.findUnique({ where: { id } }).throw();
//    ^? User

Prepared statements that keep the types

Define the read once with param(), then execute it with new values. Each param takes its type from the column or option it stands in for, and the result keeps the read's shape.

import { param } from 'better-drizzle';

const findUserByEmail = client.users
	.findUnique({ where: { email: param('email') } })
	.prepare('users.by-email');

const user = await findUserByEmail.execute({ email: 'user@example.com' });
//    ^? User | null       execute({ email: 1 }) is a type error

Every read can be prepared, including paginate() and cursor(). Plugin transforms, before hooks, and beforeQuery run once at prepare time; afterQuery, intercepts (including the cache plugin), and plugin after hooks run on every execution. See prepared statements.

JSONB that the compiler understands

Declare the shape with Drizzle's $type<T>() and every scalar leaf becomes a typed dot path. PostgreSQL.

const referrals = await client.accounts.findMany({
	where: {
		settings: { json: { referrer: { endsWith: '@acme.com' } } },
	},
});

Paths and values are bound parameters, and the generated predicate is guarded by jsonb_typeof, so one row with the wrong type cannot break the cast.

The same dot paths work for partial updates through jsonb_set, leaving the rest of the document untouched:

await client.accounts.update({
	where: { id },
	data: { settings: { 'plan.tier': 'pro' } },
});

On typed JSONB columns, dotted paths and the { json: ... } wrapper both check paths and values against $type<T>(); use the wrapper for single-level keys. Path updates create missing object ancestors, treat SQL NULL and non-object JSONB roots as {}, and preserve existing object ancestors and unrelated keys. A scalar, array, or JSON null at an intermediate path is replaced with {}. Duplicate or ancestor/descendant paths are rejected, as are values containing nested undefined. Untyped JSONB columns keep open path names and JSON-encodable values.

Your filters, inside raw Drizzle

const rows = await db
	.select()
	.from(users)
	.where(client.users.$where({ age: { gte: 18 }, posts: { some: { published: true } } }));

$where() compiles the same typed where as findMany into a Drizzle SQL condition (undefined when empty), for joins, subqueries, and hand-written queries. It is pure compilation: plugin filters such as soft-delete visibility are not applied.

Row locks with guardrails

const users = await client.users.findMany({
	where: { active: true },
	lock: {
		mode: 'update',
		skipLocked: true,
	},
});

PostgreSQL and MySQL. SQLite fails fast instead of silently dropping the lock, and locks: { transactionsOnly: true } enforces that locked reads only run inside a transaction.

Transactions

The callback receives a full client bound to the transaction, so delegates, plugins, hooks, and nested savepoints all keep working.

await client.transaction(async (tx) => {
	const user = await tx.users.create({
		data: { name: 'Alice', email: 'alice@example.com' },
	});

	tx.afterCommit(() => sendWelcomeEmail(user.email));
});

Also available: automatic retries on deadlock and serialization failures, afterRollback, and isolation levels where the dialect supports them.

Plugins

Package setup, transforms, and typed extensions once, instead of wrapping better(...) yourself.

import { recommended, rules } from 'better-drizzle/rules';
import { softDelete } from 'better-drizzle/soft-delete';
import { timestamps } from 'better-drizzle/timestamps';
import { zod } from 'better-drizzle/zod';

const client = better(db, {
	plugins: [rules(recommended()), timestamps(), softDelete(), zod()],
});

That gets you runtime guardrails, automatic timestamps, soft deletes with restore(), and Zod schemas generated from your tables at client.users.$zod. Plugins can add their own typed operation args, so client.users.findMany({ deleted: 'only' }) type-checks.

Pair better-drizzle/eslint with the runtime rules to catch the statically-checkable subset in your editor.

better-drizzle/ata (experimental) validates the same operations against compiled JSON Schema, as a faster alternative to the Zod plugin. See ata.

Warning

better-drizzle/cache is experimental in 0.3.x: its options, $cache API, store interface, and entry format can change in a patch release.

better-drizzle/cache caches the reads you opt into, and better-drizzle/cache/redis stores them in the Redis client you already have. Observed writes invalidate declared dependencies after they commit, including included relations and declared foreign-key cascades. Commit and invalidation are separate operations; concurrent reads and store failures can leave stale results cached, so use database reads when consistency must be exact. No Redis key is ever scanned. Raw Drizzle writes still need a manual $cache.invalidate().

import { cache } from 'better-drizzle/cache';
import { redis } from 'better-drizzle/cache/redis';

const client = better(db, {
	plugins: [cache({ store: redis({ client: redisClient }), ttl: '5m' })],
});

const user = await client.users.findUnique({ where: { id }, cache: true });

Performance

Measured against raw Drizzle doing the same work and returning the same shape - not against a lower-level query that does less.

  • 9.1× faster relation loading (10.24 ms → 1.12 ms), and the gap widens with the number of parent rows
  • every other read within ~9%, writes within ~5%
  • prepared reads within ~4% of Drizzle's own prepared statements, and 1.5–3.1× faster than the same read unprepared
  • zero runtime dependencies

Relation loading wins because the batched loader issues one query per relation node instead of the per-row work the equivalent hand-written code ends up doing. Numbers are SQLite in-memory to isolate wrapper overhead from I/O; reproduce them with bun run bench:report.

Full tables, methodology, and the cases where the wrapper costs you: benchmarks.

There is more

.explain() on any read without running it · updateEach for per-row batch updates in one statement · upsertMany · $withContext for request-scoped metadata · extends() for your own helpers · raw SQL with its own hooks · lifecycle hooks for auditing and tracing.

Atomic updates (increment, decrement, multiply, divide, toggle) · typed PostgreSQL array filters and mutations.

AI agents

better-drizzle ships a first-party skill pack - SKILL.md plus task references - for coding agents that need accurate API guidance and review guardrails. Zero scripts, zero network. See the AI docs.

Docs

Getting started · Querying · Writing · Plugins · Why better-drizzle? · Limitations

Contributors

contributors

License

Apache-2.0

Projets similaires

ORM

TypeScriptbunjsmysqlnodejs
Ddrizzle-team
35,9 k étoiles1,7 k

A collection of useful utilities and extensions for Drizzle ORM

TypeScriptdrizzle-ormjavascriptnodejs
Aalloc
173 étoiles8

Next-generation ORM for Node.js & TypeScript | PostgreSQL, MySQL, MariaDB, SQL Server, SQLite, MongoDB and CockroachDB

TypeScriptcockroachdbdatabasejavascript
Pprisma
47,7 k étoiles2,5 k