Minimal, type-safe repository helpers for Drizzle ORM.
better-drizzle wraps an existing Drizzle client and gives each table a small, consistent API for reads, writes, pagination, nested filters, relation loading, and optional hooks. The goal is simple: keep Drizzle's type-safety, remove repetitive query glue, and stay close enough to the metal that performance still matters.
Website: https://better-drizzle.vercel.app/
npm install better-drizzle drizzle-ormDrizzle is excellent when you want explicit SQL-first control.
It gets repetitive when every service ends up re-writing the same patterns:
- point lookups
- relation includes
- pagination payloads
- existence checks
- count helpers
- CRUD return shapes
- nested
wherefilters
better-drizzle packages those patterns into a small repository-style API without trying to replace Drizzle itself.
- Less repeated query code for common CRUD flows
- Nested relation filters with Drizzle-backed typing
includeandselectsupport with typed payload inference- Unified pagination return shape
- Optional lifecycle hooks for cross-cutting behavior
- First-class plugins with setup, transforms, and client/model extensions
- Fast paths for simple reads and writes to reduce wrapper overhead
- Consistent table delegates:
findMany,findFirst,create,update,updateEach,delete,paginate,count,exists,upsert,upsertMany
import { better } from 'better-drizzle';
import { drizzle } from 'drizzle-orm/better-sqlite3';
const db = drizzle(sqlite, { schema });
const client = better(db, { schema });
const user = await client.users.findFirst({
where: { id: 1 },
});
const posts = await client.posts.findMany({
where: {
published: true,
author: {
is: {
active: true,
},
},
},
include: {
author: true,
},
orderBy: [{ id: 'desc' }],
take: 20,
});Check whether a user exists or not and count after it.
const exists = await client.users.exists({
where: { id: 123 },
});
const count = await client.users.count({
where: {
name: { contains: 'drizzle-orm' },
},
});Create and update the user.
const someUser = await client.users.create({
data: {
id: 123,
name: 'better',
},
});
const user = await client.users.update({
data: {
name: 'better-drizzle',
},
where: { id: someUser.id },
});
const maybeCreated = await client.users.create({
data: {
email: 'better@example.com',
id: 124,
name: 'better-again',
},
skipDuplicates: true,
});
if (!maybeCreated) {
console.log('user already existed');
}
const batch = await client.users.upsertMany({
data: [
{ id: 123, name: 'better', email: 'better@example.com', active: true },
{ id: 124, name: 'batch', email: 'batch@example.com', active: false },
],
target: 'email',
update: ['name', 'active'],
select: {
id: true,
name: true,
},
});You can where queries like drizzle too
const { count } = await client.users.delete({
where: eq(users.id, 123),
});You can also resolve repositories dynamically.
const users = client.repository('users');The repository name can be the TypeScript schema key or the database table name.
Transactions live on the client, not on individual models. The callback receives a full Better Drizzle client bound to the underlying transaction, so model delegates, plugin args, transforms, hooks, and nested transactions all keep working.
const user = await client.transaction(async (tx) => {
const created = await tx.users.create({
data: {
email: 'better@example.com',
id: 123,
name: 'better',
},
});
tx.afterCommit(async () => {
await sendWelcomeEmail(created.email);
});
return created;
});Plugins let you package setup logic, query transforms, and reusable client/model extensions without wrapping better(...) yourself.
Plugins can also extend the built-in operation args in a fully typed way through operationArgs, so custom fields like deleted or mode flow from the delegate call into plugin transforms and hooks.
import { better } from 'better-drizzle';
import { timestamps } from '@better-drizzle/timestamps';
import { softDelete } from '@better-drizzle/soft-delete';
const client = better(drizzle, {
schema,
plugins: [
timestamps({
createdAt: 'created_at',
updatedAt: 'updated_at',
}),
softDelete({
column: 'deletedAt',
deletedByColumn: 'deletedById',
defaults: {
mode: 'soft',
visibility: 'without',
},
}),
],
});
await client.users.delete({
where: { id: 1 },
});
await client.users.findMany({
deleted: 'only',
});
await client.users.restore({
where: { id: 1 },
});Now you can soft delete rows easily and also have timestamps fields injected automatically.
The client accepts optional hooks through better(db, options). This is useful for auditing, tracing, metrics, authorization, and other cross-cutting concerns that you do not want duplicated in every repository call.
The hook layer is optional. If you do not need it, do not pass it.
Always assign a random UUID in the user before creating it.
const client = better(drizzle, {
schema,
hooks: {
beforeCreate({ data: user }) {
user.organizationId = randomUUID();
},
},
});meta can still be passed per call, but you can now scope default metadata once on the client with $withContext(...). Scoped values are merged into repository calls, raw SQL hooks, and transaction hooks, and per-call meta overrides matching keys.
type RequestMeta = {
organizationId?: string;
requestId?: string;
userId?: string;
};
const client = better<typeof schema, RequestMeta>(drizzle, { schema });
const scoped = client.$withContext({
organizationId: 'org_123',
requestId: 'req_123',
});
await scoped.users.create({
data: { name: 'Alice' },
meta: { requestId: 'req_456', userId: 'user_7' },
});See the full benchmark suite and results in benchmark/README.md. The suite covers reads, writes, and transactions (including nested savepoints) with fair API-parity comparisons against raw Drizzle.
