Skip to content

TypeScript Patterns

Use PluginDb for database access in plugins

Section titled “Use PluginDb for database access in plugins”

Plugin routes and services receive a typed Drizzle database instance. Import PluginDb from core — never define your own PgDatabase type.

src/plugins/loyalty-plugin.ts
import type { PluginDb } from "@porulle/core";
class LoyaltyService {
constructor(
private db: PluginDb,
private tierThresholds: { silver: number; gold: number; platinum: number },
) {}
async getPoints(orgId: string, customerId: string) {
return this.db
.select()
.from(loyaltyPoints)
.where(and(eq(loyaltyPoints.orgId, orgId), eq(loyaltyPoints.customerId, customerId)));
}
}

In a plugin’s routes callback, ctx.database.db is already typed as PluginDb. Use it directly:

routes: (ctx) => {
const db = ctx.database.db; // typed as PluginDb — no cast needed
const service = new LoyaltyService(db, options.tierThresholds);
return buildRoutes(service, ctx);
},

If you see ctx.database.db as unknown as Db anywhere, remove it.

If your service needs transactions, accept PluginTxFn:

import type { PluginDb, PluginTxFn } from "@porulle/core";
class GiftCardService {
constructor(
private db: PluginDb,
private txFn: PluginTxFn,
) {}
async debitWithLock(orgId: string, code: string, amount: number) {
return this.txFn(async (tx) => {
// tx is typed as PluginDb — use it directly
await tx
.update(giftCards)
.set({ balance: sql`balance - ${amount}` })
.where(and(eq(giftCards.orgId, orgId), eq(giftCards.code, code)));
});
}
}
TypeSchemaUse in
DrizzleDatabasePgDatabase<HKT, Schema> — full core schemaCore repositories, kernel services
PluginDbPgDatabase<HKT, Record<string, unknown>> — opaquePlugins, hooks, jobs

Core repositories need the concrete schema type for relational queries (db.query.sellableEntities). Plugins don’t — they import their own table definitions and use db.select().from(myTable).

The single cast from PluginDb to DrizzleDatabase happens once in the kernel at boot time — not in service or plugin code.

The codebase follows a rule borrowed from tRPC and oRPC: double-casts are only permitted at third-party SDK boundaries, never in internal type pipelines.

Permitted (third-party boundary):

// Better Auth's return type is a generic plugin union we can't express.
return auth as unknown as AuthInstance;

Not permitted (internal code):

// BAD — ctx.database.db is already PluginDb
const db = ctx.database.db as unknown as Db;
// GOOD — use directly
const db = ctx.database.db;

If TypeScript forces a double-cast on code you own, the type design is wrong. Fix the interface, don’t add a cast. When you must double-cast at a third-party boundary, add a comment identifying which upstream gap requires it.

If a service isn’t available at registration time (hooks register before routes), use a Proxy with Reflect.get:

const lazyService = new Proxy({} as GiftCardService, {
get(_target, prop, receiver) {
if (!serviceRef.current) {
throw new Error("Service not initialized — hooks ran before routes()");
}
return Reflect.get(serviceRef.current, prop, receiver);
},
});

This avoids as unknown as Record<string, unknown> when accessing dynamic properties.

Drizzle returns null for nullable columns, never undefined. Use loose equality (!= null) to catch both:

// GOOD — catches both null and undefined
if (variantId != null) {
query = query.where(eq(inventoryLevels.variantId, variantId));
} else {
query = query.where(isNull(inventoryLevels.variantId));
}
// BAD — null passes this check; eq(col, null) generates `col = NULL` (always 0 rows)
if (variantId !== undefined) {
query = query.where(eq(inventoryLevels.variantId, variantId));
}

Never pass null to Drizzle’s eq(). Use isNull(column) for IS NULL checks.

When building a HookContext, pass structured fields instead of casting the kernel:

// GOOD — pass only the fields the function needs
const context = createHookContext({
actor,
logger: kernel.logger,
services: kernel.services as ServiceContainer,
context: { moduleName: "checkout" },
origin: "rest",
kernel: { database: { db: kernel.database.db } },
});
// BAD — casts entire kernel through unknown
kernel: kernel as unknown as { database: { db: PluginDb } },
Terminal window
# Full project
bun run check-types
# Single package
cd packages/core && npx tsc --noEmit

The type-check catches casts that are no longer necessary. If TypeScript reports “Conversion of type X to type Y may be a mistake”, the types are already compatible — remove the as unknown as and use a direct as or no cast at all.