Type-Safe SQL Without an ORM
ORMs are a tax on performance and understanding. We wrote type-safe SQL queries using TypeScript's type system instead.
Every ORM is a leaky abstraction that costs you performance, understanding, and eventually your sanity. We know this because we have used all of them. Sequelize. TypeORM. Prisma. Drizzle. Kysely. Each one promises to free you from SQL and each one eventually forces you back to it, except now you are writing SQL inside a framework that fights you.
We write SQL. Raw, explicit, optimized SQL. And we get full type safety at compile time without an ORM, without code generation, and without a build step. Here is how.
The Problem with ORMs
ORMs have three structural problems that no amount of engineering can fix:
N+1 queries by default. ORMs model relationships as lazy-loaded properties. Access campaign.ads and the ORM emits a SELECT. Do that inside a loop and you get N+1 queries. Yes, every ORM has eager loading. No, it does not help. Eager loading requires the developer to know ahead of time which relationships will be accessed, which is the exact knowledge the ORM was supposed to abstract away. The default behavior is wrong, and defaults determine system behavior because developers follow the path of least resistance.
Impedance mismatch. SQL operates on sets. ORMs operate on objects. These are fundamentally different computational models. A SQL query that joins three tables, filters, groups, and aggregates produces a flat result set. An ORM insists on hydrating this into a graph of nested objects with identity tracking, change detection, and relationship management. This hydration is expensive (Prisma's deserialization overhead is measurable at our scale), it is lossy (aggregations do not map to objects), and it obscures what the database is actually doing.
Inevitable escape to raw SQL. Every ORM project of sufficient complexity contains raw SQL queries. Window functions, CTEs, lateral joins, database-specific features, performance-critical queries that the ORM generates poorly. The moment you write raw SQL inside an ORM, you lose type safety, you lose the ORM's caching and change tracking, and you have two query paradigms in one codebase. This is strictly worse than having one paradigm.
Our Approach: Template Literal Type Inference
TypeScript's template literal types can parse strings at the type level. We built a thin layer called tsql (not a real library, just 400 lines of TypeScript type gymnastics) that takes a SQL query as a template literal and infers the result type at compile time.
The core idea: given a SQL string and a schema definition, TypeScript's type system can determine what columns the query returns and what types they have.
// Schema definition - the single source of truth
interface Schema {
campaigns: {
id: number
name: string
budget_cents: number
status: "active" | "paused" | "archived"
created_at: Date
}
ads: {
id: number
campaign_id: number
creative_url: string
impressions: number
clicks: number
}
}
// The query - plain SQL, nothing special
const result = await db.query<Schema>()`
SELECT c.id, c.name, c.budget_cents, COUNT(a.id) as ad_count
FROM campaigns c
LEFT JOIN ads a ON a.campaign_id = c.id
WHERE c.status = ${"active"}
GROUP BY c.id, c.name, c.budget_cents
`
// typeof result: Array<{
// id: number
// name: string
// budget_cents: number
// ad_count: number
// }>
The type system infers the return type from the SELECT clause. It knows c.id comes from campaigns.id which is number. It knows COUNT(a.id) produces number. It knows the WHERE clause parameter must match the type of campaigns.status. All at compile time. No code generation. No build step. No runtime overhead.
The template literal parser handles:
- SELECT with aliases:
SELECT c.name as campaign_nameinfers{ campaign_name: string } - Aggregate functions:
COUNT,SUM,AVG,MIN,MAXwith correct return types - JOINs: Resolves table aliases to schema types
- Parameterized queries:
${value}is type-checked against the column it is compared to - Subqueries: Nested SELECTs are recursively type-inferred
Is this approach limited? Of course. It does not handle every SQL feature. Dynamic column selection, complex CASE expressions, and some database-specific functions fall outside what template literal types can parse. For those cases, we use an explicit type annotation:
const result = await db.query<Schema, CustomResultType>()`
SELECT complicated_stuff...
`
This is not a failure of the approach. It is an honest acknowledgment that 85% of queries are simple enough for automatic inference, and the remaining 15% get manual types. With an ORM, 100% of complex queries require escaping to raw SQL with manual types anyway. We just start from a better baseline.
Integration with Our Zig Database
Our ad-serving data lives in a custom database written in Zig (we will write about this separately). The query layer communicates over a custom binary wire protocol, not PostgreSQL's protocol, not MySQL's. This is another reason ORMs were never an option: they all assume a standard database protocol.
The wire protocol is designed for minimal allocation on the TypeScript side. Query results arrive as binary frames that map directly into TypeScript ArrayBuffer instances. Our deserialization layer reads typed values straight from the buffer:
class BinaryReader {
private view: DataView
readInt64(): bigint {
const val = this.view.getBigInt64(this.offset, true) // little-endian
this.offset += 8
return val
}
readString(): string {
const len = this.view.getUint32(this.offset, true)
this.offset += 4
const bytes = new Uint8Array(this.buffer, this.offset, len)
this.offset += len
return decoder.decode(bytes)
}
readRow<T>(columns: ColumnDef[]): T {
const row: Record<string, unknown> = {}
for (const col of columns) {
row[col.name] = this.readByType(col.type)
}
return row as T
}
}
The column definitions arrive as part of the response header, and they include type information that we validate against the TypeScript-inferred types at development time. If our tsql layer infers that budget_cents should be number but the database returns an int64 (which should be bigint in TypeScript), we catch the mismatch during development.
This binary protocol eliminates the overhead of text-based protocols. PostgreSQL's wire protocol sends numbers as ASCII text. Our protocol sends them as raw bytes. For a result set with 10,000 rows and 8 numeric columns, this eliminates 80,000 parseInt calls. At our query volumes, this matters.
Comparison with Existing Solutions
Prisma: Generates a typed client from a schema file. The generated code is a substantial runtime dependency (Prisma Client is not small). It uses its own query engine (written in Rust, ironically) that adds latency. The schema file is a separate DSL that must be kept in sync with the actual database. And the moment you need a query Prisma's API cannot express, you drop to $queryRaw and lose all type safety. We respect Prisma's developer experience for CRUD applications. We are not building CRUD applications.
Drizzle: Closer to what we want. Drizzle lets you write SQL-like queries in TypeScript with full type inference. But it is still a query builder, not SQL. db.select().from(campaigns).where(eq(campaigns.status, "active")) is not SQL. It is a TypeScript DSL that generates SQL, with all the abstraction leakage that implies. Complex queries become awkward chains of method calls that are harder to read than the SQL they produce. Drizzle is the best ORM. It is still an ORM.
Kysely: A type-safe SQL query builder. Better than Drizzle in our opinion because it stays closer to SQL syntax. But it still has a query builder API (db.selectFrom("campaigns").select(["id", "name"]).where("status", "=", "active")) that is neither SQL nor TypeScript. You have to learn Kysely's API in addition to SQL. With our approach, you just write SQL. If you know SQL, you know our query layer.
pgtyped / PgTyped: Closest to our philosophy. It parses SQL files and generates TypeScript types. But it requires a build step, a running PostgreSQL instance for type inference, and it is PostgreSQL-specific. Our approach works at compile time with no external dependencies and no code generation.
The Philosophy
SQL is a language. It has been the standard database query language for 50 years. It is declarative, composable, well-documented, and understood by every backend engineer on earth. The database's query optimizer understands SQL. Monitoring tools understand SQL. EXPLAIN plans describe SQL.
ORMs exist because writing SQL in application code used to mean string concatenation and runtime type errors. That was a real problem. But TypeScript's type system is powerful enough to solve it without adding an abstraction layer between you and your database.
We write SQL. TypeScript checks it at compile time. The database executes it directly with no intermediate query engine, no hydration overhead, no impedance mismatch. Our query layer is 400 lines of type definitions and 200 lines of runtime code. It has no dependencies. It has no bugs, because there is almost no code.
ORMs are a tax on performance and understanding. We stopped paying it.