ORMs sell you on skipping SQL. Push one hard enough and you end up writing SQL anyway, just indirectly, with an extra layer to understand on top of it.

Write the SQL directly and have the type system understand it.

What the end state looks like

const users = await db.query(sql`
  select id, name, email from users where active = ${true}
`)
// users: Array<{ id: number; name: string; email: string }>

No schema duplication. No query builder. The SQL is the source of truth and the types follow from it automatically.

How it actually works

There are three pieces that have to fit together.

The first is schema introspection at build time. You read the database and generate a Tables type that maps table and column names to their TypeScript equivalents. This is the foundation everything else rests on.

The second is a template literal parser at the type level. TypeScript's type system is capable enough to parse a SQL string literal and extract the selected columns from it. This sounds unreasonable until you see it working. The parser runs entirely at compile time and produces a typed result shape from the query itself.

The third piece is a runtime that does almost nothing. It takes the SQL string and sends it to the database driver. No parsing, no transformation. The heavy lifting was already done by the type system.

Why do it this way

A runtime SQL parser can disagree with the database. It might parse your query fine but produce subtly different semantics. The type-level approach skips that problem entirely because there is no runtime parser. The types are a compile-time lint pass. They disappear when the code runs.

Where it falls short

It only works for SQL the parser understands. Complex vendor-specific expressions need an escape hatch, usually something like sql.unsafe, which opts that query out of type inference. The error messages are also TypeScript error messages, which can be dense. Once you've seen a few of them you learn to read them quickly, but the first few are disorienting.

For anything where SQL is the natural way to express the problem, which for me is most of the time, this is a better tradeoff than an ORM. The query says what it does. The types agree with it. There's nothing in between to misunderstand.