better-drizzle

Client extensions

Attach app-specific helpers and shared values to a Better Drizzle client with extends().

client.extends(...) adds application-specific helpers or shared values to a Better Drizzle client. It takes a plain object or a callback, merges the result onto the client, and reapplies it to every $withContext() clone and transaction client derived later.

Object form and callback form

The object form is for static values:

const client = better(db).extends({
	limits: { maxPageSize: 100 },
});

client.limits.maxPageSize; // 100

The callback form receives the client it is being applied to and returns the extension:

const client = better(db).extends((client) => ({
	findUserByEmail(email: string) {
		return client.users.findUnique({ where: { email } });
	},
}));

const user = await client.findUserByEmail('alice@example.com');

The callback runs immediately for the current client, then again for each derived client: every $withContext() clone, every transaction client, and every nested savepoint client. Inside it, client is that derived instance, so a helper called as tx.findUserByEmail(...) queries through the transaction.

The object form does not get this. The same object is copied onto each derived client, so a method that closes over an outer variable keeps using that variable:

const base = better(db);

const client = base.extends({
	countUsers: () => base.users.count(), // always the root client
});

await client.transaction(async (tx) => {
	await tx.countUsers(); // runs on the root client, not on tx
});

Use the object form for values, and the callback form for anything that runs queries.

Returning undefined from the callback adds nothing. extends() modifies the client it is called on and returns that same instance.

Typing

extends() returns the client type intersected with the extension type:

lib/db.ts
const base = better(db);

const helpers = (client: typeof base) => ({
	findUserByEmail(email: string) {
		return client.users.findUnique({ where: { email } });
	},
	publishPost(id: number) {
		return client.posts.update({ where: { id }, data: { published: true } });
	},
});

export const client = base.extends(helpers);
export type AppHelpers = ReturnType<typeof helpers>;

The extension keys stay typed on every client derived from the extended one:

  • Chained calls keep every extension. client.extends(a).extends(b) has the keys of both a and b.
  • $withContext() clones keep the extension keys.
  • The tx parameter of transaction() keeps the extension keys.
import { client } from './lib/db';

const scoped = client.$withContext({ requestId });
await scoped.findUserByEmail('alice@example.com');

await client.transaction(async (tx) => {
	await tx.publishPost(1);
});

Keys added by a plugin's extendClient() are typed the same way. See extends vs plugins.

Conflicts fail fast

An extension cannot replace an existing key. The call throws BetterDrizzleError with code PLUGIN_EXTENSION_CONFLICT and the message Client extension cannot override "<key>". This covers:

  • built-in client keys such as repository, transaction, $raw, and $withContext
  • table delegates such as users
  • keys added by plugins through extendClient()
  • keys added by an earlier extends() call

Plugin extensions are applied first, so a plugin key always wins the conflict and the extends() call is the one that fails. A key that conflicts with the client type is also a compile-time error.

Call extends() once, at startup

Extensions are registered on state shared by the root client and everything derived from it. Calling extends() on a per-request $withContext() clone registers the extension for all future clones, and the next request that adds the same key fails with a conflict. Register extensions where you build the client, not in request handlers.

Example: domain helpers that respect transactions

Helpers that compose several operations can call client.transaction(...). Because the callback form rebinds client, the same helper opens a transaction when called on the root client and a nested savepoint when called on a transaction client:

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

const helpers = (client: typeof base) => ({
	async transferPost(postId: number, toUserId: number) {
		return client.transaction(async (tx) => {
			await tx.users.findUnique({ where: { id: toUserId } }).throw();

			return tx.posts.update({
				where: { id: postId },
				data: { authorId: toUserId },
			});
		});
	},
});

const client = base.extends(helpers);

// Own transaction
await client.transferPost(10, 2);

// Savepoint inside a larger transaction, rolled back with it
await client.transaction(async (tx) => {
	await tx.transferPost(10, 2);
	await tx.users.update({ where: { id: 1 }, data: { active: false } });
});

Example: shared services next to the data

An extension can carry values that helpers and callers both need, such as configuration, next to the helpers that use them:

const base = better(drizzle({ client: pool, relations }));
const config = { maxPageSize: 100 };

const helpers = (client: typeof base) => ({
	config,
	listPublished(page: number, perPage = 20) {
		const limit = Math.min(perPage, config.maxPageSize);

		return client.posts.paginate({
			where: { published: true },
			orderBy: [{ id: 'desc' }],
			limit,
			skip: (page - 1) * limit,
		});
	},
});

const client = base.extends(helpers);

const { data, pagination } = await client.listPublished(2);
const { maxPageSize } = client.config;

Plain values are safe in either form, because they do not depend on which client they are attached to.

extends vs plugins

Use extends() when:

  • the helper is specific to this application and its tables
  • you only need client-level helpers or shared values
  • you do not need transforms, lifecycle hooks, or typed operation args

Use a plugin with extendClient() when:

  • the behavior is reusable across schemas, since plugin code sees the schema generically rather than as your tables
  • the extension must be typed on scoped and transaction clients without assertions
  • it has to change repository operations, add model-level helpers, or observe hooks

A plugin's extendClient(ctx) also runs for every bound client, and ctx.client is that client, so the same rebinding rules apply.

On this page