better-drizzle

Typing services

Type reusable where, select, and include objects, services that take a client or a transaction, plugin clients, and meta.

Use these patterns when query objects and data-access functions are shared across files. Result types are covered in typing results.

Reuse where, select, and include objects

Argument objects defined outside the call need care, because TypeScript widens true to boolean in a plain object, and select only keeps keys typed as true.

Do not annotate select objects

const select: SelectInput<Schema, 'users'> = { id: true } compiles, but the declared type is boolean, so the payload type loses every selected key. The same happens with a plain const select = { id: true }. The rows still contain the fields at runtime; only the type is wrong.

Use satisfies. It checks the object against the exported argument type and keeps the literal types:

import type {
	IncludeInput,
	OrderByInput,
	SelectInput,
	WhereInput,
} from 'better-drizzle';

type Schema = typeof relations;

const publishedOnly = {
	published: true,
	title: { contains: 'drizzle' },
} satisfies WhereInput<Schema, 'posts'>;

const postCard = {
	id: true,
	title: true,
	author: { select: { id: true, name: true } },
} satisfies SelectInput<Schema, 'posts'>;

const withRecentPosts = {
	posts: { orderBy: { id: 'desc' }, take: 3 },
} satisfies IncludeInput<Schema, 'users'>;

const newestFirst = { id: 'desc' } satisfies OrderByInput<Schema, 'posts'>;

const cards = await client.posts.findMany({
	where: publishedOnly,
	select: postCard,
	orderBy: newestFirst,
});
// { id: number; title: string; author: { id: number; name: string } | null }[]

as const also keeps literals and is enough for flat select objects:

const userSummary = { id: true, name: true } as const;

const users = await client.users.findMany({ select: userSummary });
// { id: number; name: string }[]

Prefer satisfies for anything that contains an array. as const makes arrays readonly, and in, notIn, AND, OR, and multi-column orderBy expect mutable arrays, so the object no longer matches.

A where object does not affect the payload type, so a plain annotation is fine there:

import type { WhereInput } from 'better-drizzle';

const where: WhereInput<typeof relations, 'users'> = { active: true };

The exported argument types, all generic over <Schema, Table>:

  • WhereInput (structured filters) and WhereArg (WhereInput, or a Drizzle SQL / SQLWrapper)
  • SelectInput, IncludeInput, OrderByInput, CursorInput
  • QueryArgs for findMany / findFirst / findOne / findUnique; CountArgs, ExistsArgs, PaginationArgs, CursorArgs
  • CreateArgs, CreateManyArgs, UpdateArgs, UpdateManyArgs, UpsertArgs, UpsertManyArgs, UpdateEachArgs, DeleteArgs, DeleteManyArgs
  • CreateDataInput and UpdateDataInput for data payloads

The operation argument types (QueryArgs, CreateArgs, and so on) take an optional third parameter for your meta type.

Type services that take a client or a transaction

Export the client type once and use it for every function that queries:

db.ts
export const client = better(db);
export type Db = typeof client;

A transaction client is the root client type plus transaction, rollback, afterCommit, and afterRollback, so it is assignable to Db. The same function then works inside and outside a transaction:

posts.service.ts
import type { Db } from './db';

export async function publishDraft(db: Db, postId: number) {
	const post = await db.posts
		.update({ where: { id: postId }, data: { published: true } })
		.throw();

	return db.users.findUnique({ where: { id: post.authorId } }).throw();
}

await publishDraft(client, 10);

await client.transaction(async (tx) => {
	await publishDraft(tx, 10);
	await publishDraft(tx, 11);
});

When a function needs the transaction-only methods, ask for the transaction client type instead. Deriving it from Db keeps plugins and meta in sync:

import type { Db } from './db';

type Tx = Parameters<Parameters<Db['transaction']>[0]>[0];

export async function publishAndNotify(tx: Tx, postId: number) {
	await tx.posts.update({ where: { id: postId }, data: { published: true } });
	tx.afterCommit(() => notifySubscribers(postId));
}

For a client without plugins or a custom meta, the exported types say the same thing directly:

import type {
	BetterDrizzleClient,
	BetterDrizzleTransactionClient,
} from 'better-drizzle';

type Db = BetterDrizzleClient<typeof relations>;
type Tx = BetterDrizzleTransactionClient<typeof relations>;

To accept a single table's delegate, index the client type:

type UsersRepository = Db['users'];

export function findByEmail(users: UsersRepository, email: string) {
	return users.findUnique({ where: { email } });
}

await findByEmail(client.users, 'ada@example.com');

BetterDrizzleModelDelegate<Schema, Table, Meta, Plugins> is the same type written out.

Clients built with extends()

typeof client includes keys added with client.extends(...), and so do $withContext() clones and the tx passed to transaction(). See Client extensions.

Clients with plugins

Plugins add to the client type: extra operation arguments (deleted from soft delete), model methods (restore), and client keys. typeof client carries all of them, including on transaction clients:

import { better } from 'better-drizzle';
import { softDelete } from 'better-drizzle/soft-delete';

export const client = better(db, {
	plugins: [softDelete()],
});
export type Db = typeof client;

export function listUsers(db: Db) {
	return db.users.findMany({ deleted: 'with' }); // typed by the plugin
}

BetterDrizzleClient<typeof relations> does not know about plugins, so deleted is a type error there. Use typeof client, or pass the plugin tuple as the third type argument.

Typed meta

meta is Record<string, unknown> by default. Pass your own type as the second type argument of better():

lib/db.ts
export type AppMeta = {
	requestId?: string;
	tenantId?: string;
	userId?: number;
};

export const client = better<typeof relations, AppMeta>(db, {
	hooks: {
		afterCreate(ctx) {
			log(ctx.table, ctx.meta?.requestId); // ctx.meta: AppMeta | undefined
		},
	},
});

const scoped = client.$withContext({ requestId: 'req_1' });

await scoped.users.create({
	data: { email: 'ada@example.com', name: 'Ada' },
	meta: { userId: 1 },
});

await client.users.findMany({ meta: { userId: '1' } }); // type error: userId is a number

What the type controls:

  • $withContext(meta) accepts Partial<AppMeta>
  • per-call meta on every operation, raw query, and transaction(..., { meta }) accepts AppMeta
  • hook and plugin contexts expose ctx.meta as AppMeta | undefined

Per-call meta is the full AppMeta, not a partial, so keep its keys optional unless every call must pass them.

TypeScript does not infer the remaining type arguments once you pass some. With plugins, pass the plugin tuple as well:

import type { AppMeta } from './lib/db';

const plugins = [softDelete()] as const;

export const client = better<typeof relations, AppMeta, typeof plugins>(db, {
	plugins,
});

On this page