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; // 100The 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:
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 bothaandb. $withContext()clones keep the extension keys.- The
txparameter oftransaction()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.