Skip to content

Widening a Drizzle Database Type to Test Against Local Postgres

Published: 2026-07-14

I’m building a billing backend on Neon, whose serverless Postgres I reach through Drizzle’s neon-http driver — an HTTP-only client with no persistent socket. When I sat down to write integration tests for the repository layer, I didn’t want to mock the query builder. I wanted a real round trip against a real database: spin up a plain Postgres in Docker, point a repository at it, and assert on what actually came back.

That plan hit a wall at the type layer before it ever reached the database. This is the small change that got past it — and why the fix most guides reach for solves a different problem than mine.

The type that only fit one driver

The repository’s Database type was derived straight from the neon-http client:

import { neon } from '@neondatabase/serverless';
import { drizzle } from 'drizzle-orm/neon-http';
import * as schema from './schema.js';

export type Database = ReturnType<typeof createNeonHttpClient>;

export function createNeonHttpClient(connectionString: string) {
  return drizzle(neon(connectionString), { schema });
}

ReturnType<typeof createNeonHttpClient> resolves to NeonHttpDatabase<typeof schema> — the type Drizzle hands back for the HTTP driver specifically. That is exactly right for that driver, and exactly the problem in a test. A local Docker Postgres speaks the ordinary Postgres wire protocol, which Drizzle reaches through node-postgres (the standard pg driver). So my test built a NodePgDatabase and passed it into a repository constructor typed for Database:

error TS2322: Type 'NodePgDatabase<typeof schema> & { $client: Pool; }' is not
assignable to type 'NeonHttpDatabase<typeof schema> & { $client: NeonQueryFunction<false, false>; }'.
  Type 'NodePgDatabase<...>' is missing the following properties from type
  'NeonHttpDatabase<...>': $withAuth, batch

For everything my repository does — the ordinary select/insert/update/delete the query builder compiles to SQL — the two drivers run the same statements, and because neon-http is a drop-in for pg that inherits its result-type parsers, they decode the rows the same way. (They do diverge on a surface my repository never touches; I’ll come back to it.) At compile time, though, they are two different classes, and Database had been pinned to one of them.

What I didn’t want to do

Two fixes were within reach and I turned both down. I could keep two repository implementations, one typed per driver — but they would be the same code twice, drifting apart the first time I touched one and forgot the other. Or I could mock the Drizzle query builder in the tests and sidestep the driver entirely. That removes the real round trip, which was the whole point; a test that never touches Postgres can’t tell me my SQL is right.

I wanted one repository, and I wanted it to run against real Postgres. So the type was the thing that had to change.

One base class under both drivers

Drizzle builds every Postgres driver on a shared foundation. In drizzle-orm/pg-core there is a generic base class:

export class PgDatabase<
  TQueryResult extends PgQueryResultHKT,
  TFullSchema extends Record<string, unknown> = Record<string, never>,
  TSchema extends TablesRelationalConfig = ExtractTablesWithRelations<TFullSchema>,
>

Its first type parameter is an HKT (a higher-kinded type — a stand-in for the driver’s query-result shape). Every concrete driver fills that slot and extends the base. Both of the drivers I cared about do exactly that:

export class NeonHttpDatabase<...> extends PgDatabase<NeonHttpQueryResultHKT, TSchema>
export class NodePgDatabase<...>   extends PgDatabase<NodePgQueryResultHKT, TSchema>

So the fix is to name the base class instead of a leaf: widen Database to PgDatabase itself, parameterized by the neutral PgQueryResultHKT.

import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
import * as schema from './schema.js';

export type Database = PgDatabase<PgQueryResultHKT, typeof schema>;

export function createNeonHttpClient(connectionString: string): Database {
  return drizzle(neon(connectionString), { schema });
}

There’s a subtlety worth pausing on, since the two extends clauses above name different query-result types and Database names a third. Each driver’s query-result type already satisfies the base’s extends PgQueryResultHKT bound — it has to, or NeonHttpDatabase extends PgDatabase<NeonHttpQueryResultHKT, …> wouldn’t have compiled in the first place. When I widened the alias to PgDatabase<PgQueryResultHKT, typeof schema> and pointed the repository at it, the compiler accepted both a Neon HTTP client and a node-postgres client, and createNeonHttpClient still type-checked returning Database. What convinced me the widening was sound was that it compiled against both — not a variance argument I worked out on paper. In a test, a node-postgres instance now slots into the same repository constructor without complaint:

const db = drizzle(pool, { schema }); // NodePgDatabase<typeof schema>
const repository = new CustomerRepository(db); // accepted as Database

One repository class, both drivers, no duplication.

There’s a real tradeoff folded into that widening, and the error had already shown it. $withAuth and batch are the two members it lists as missing from NodePgDatabase; $client is a third the error names — not missing, but typed differently on each side (Pool vs NeonQueryFunction). None of the three lives on the shared PgDatabase base, which is part of why NodePgDatabase didn’t fit in the first place. Widening createNeonHttpClient’s return type to Database means the app code sees the base and loses all three: the neon-http extras $withAuth/batch, and the typed $client handle — Pool on one driver, NeonQueryFunction on the other. For this codebase that costs nothing: the repository lives entirely on the standard query surface and reaches for none of them, and even the tests hold their pool directly rather than through db.$client. But it is a real narrowing, not a free lunch — if I needed batch, or the raw $client for pool teardown or a health check, I’d keep the concrete type at that one call site and widen only where the repository accepts an instance. (The lossless alternative is to make the repository generic over the driver type — Repo<DB extends PgDatabase<PgQueryResultHKT, typeof schema>> — which keeps each instance’s own methods, at the cost of threading that type parameter through every caller. Widening the alias keeps the call sites plain, and I don’t reach for the methods anyway.)

Switching the connection is not the same as widening the type

Here is the part I want to be careful about, because when I searched for prior art the top results pointed somewhere else. Ask how to run Drizzle against local Postgres in development and Neon in production, and the guides I found — Neon’s own included — gave the same answer: read NODE_ENV, pick a connection string, and initialize the matching driver.

That pattern is real and it works, but it solves a runtime problem: which driver do I construct right now. It never touches the type. A single exported db instance sidesteps assignability because nothing else is ever annotated. The moment you have a repository or a service that names a Database type and needs to accept a driver instance from either side — app code in one place, a test in another — the runtime switch has nothing to say. That is a compile-time question, and widening the type is what answers it.

The two fixes aren’t rivals; they address different layers. I reach for the env switch when the question is “what do I connect to,” and for the widened type when the question is “what will this function accept.”

What it bought

After the change, one repository class takes both: the Neon HTTP client the app already used, and a node-postgres client pointed at Docker Postgres in the tests. The tests compile, spin up a real database, and exercise a real round trip — which is all I wanted at the start. The whole fix was a single type alias and one import; the drivers were already built to make it possible.

What the local test does and doesn’t prove

There’s a boundary here worth drawing, because the test driver isn’t the one the app uses. What the local run proves is that my SQL is right and my rows come back the way I expect — the query builder emits the same statements against both, and since neon-http inherits pg’s result parsers, the default row decoding matches too. (One asterisk on “my SQL is right”: pin the Docker image to the Postgres major version Neon runs, or a planner or function difference between versions can slide a green test into a failure on Neon.) What it can’t prove is neon-http-specific behavior. Because neon-http runs each statement as its own request with no session continuity, anything that leans on session state diverges from a socket-based driver — transactions, SET LOCAL, temp tables, advisory locks. db.transaction() is the sharp edge of that class: it throws outright (No transactions support in neon-http driver), where node-postgres runs it fine.

That gap never bit this project, and the reason is the order I built things in: the backend was on neon-http from the start, so that whole session-dependent surface was never on the table. The repository lives on the plain per-statement query surface by premise — which is exactly what makes swapping in node-postgres for the tests safe. I’m not testing a session-stateful path against Docker that would then fail on Neon, because there is no such path.

But carry this pattern to a repository that does lean on that surface — a transaction, a session-scoped SET — and the widened type stops protecting you: transaction sits on the PgDatabase base, so it compiles against Database, a node-postgres test runs it green, and it throws on Neon. If that’s you, keep the test and neon-http on the same behavioral line. The higher-fidelity option is a throwaway Neon branch, which runs the tests on neon-http itself; I traded that for the speed and the offline, no-cloud-in-CI simplicity of local Docker — a fair trade only because the surface my repository uses is the one both drivers share.

Conclusion

Starting narrow wasn’t a mistake to walk back — I had no reason to reach for the base class until a real local test gave me one. ReturnType<typeof createNeonHttpClient> said “this is the Neon HTTP database,” and that held until the test needed it to say “this is a Postgres database.” Drizzle had already drawn that line, one generic base class under every driver, so widening to it was a small change. It’s where the type sits for now; if this backend grows a need for one of the driver-specific methods, it’ll move again — though a third Postgres driver wouldn’t force that, since it would extend the same base and slot in unchanged.

Further Reading