Every TypeScript codebase that talks SQL has the same slow leak: the queries live in one place, the row types live in another, and nothing checks them against each other or against the actual schema. Reylo — a new project from Colin McDonnell (the Zod author), 1,245 stars in its first four days on GitHub — takes the most direct possible route around the problem: you write the query in a .sql file, import it from TypeScript, and the type checker asks your database what the rows look like.
-- get_author.sql
select u.id, u.name, u.email, count(p.id) as post_count
from users u
left join posts p on p.user_id = u.id
where u.id = :id::int
group by u.id
const author = await getAuthor.run(db, { id: 1 });
// { id: number; name: string; email: string | null; post_count: number } | null
It is built on TypeScript content mappers, a feature of the TypeScript 7.1 beta that lets a package define the static type of an external file type during compilation. That means the requirements are real: Node 24+, typescript@beta, and — the interesting part — a reachable dev database while you type-check, because Reylo prepares your query against the live schema (without executing it) and reads back the type, nullability and cardinality of every column and parameter.
I set it up against a throwaway SQLite file (no server needed) with reylo / @reylosql/sqlite 0.5.0, TypeScript 7.1.0-beta, Node v24.22.1, and then tried to fool it. (One install note: my first npm i -D typescript@beta exited 1 inside a lifecycle script. The compiler it left behind ran fine anyway — beta software, honestly labeled.)
The inferred types are the runtime types
The clean project type-checks in 0.69 s cold, ~0.4 s warm, on a 2-vCPU VM. That's not the interesting number. The interesting part is where the types show up: in the error messages, when you abuse them. I wrote a file that misuses the query result five ways:
bad.ts(9,11): error TS2322: Type 'string | null' is not assignable to type 'string'.
bad.ts(10,11): error TS2322: Type 'number' is not assignable to type 'string'.
bad.ts(11,25): error TS2551: Property 'emial' does not exist on type
'{ id: number; name: string; email: string | null; post_count: number; }'.
Did you mean 'email'?
bad.ts(14,48): error TS2322: Type 'string' is not assignable to type 'number'.
bad.ts(15,43): error TS2741: Property 'id' is missing in type '{}'
but required in type '{ id: number; }'.
All five caught: email is string | null because my schema has no NOT NULL on it, while name (declared not null) is plain string; post_count is a number; the named parameter :id::int is a required number. And these aren't annotations someone wrote — running the same queries for real returned exactly those shapes, including null for a nonexistent author and email: null for the user who has none:
author 1: { id: 1, name: 'ada', email: 'ada@example.com', post_count: 2 }
author 99: null
The | null on the single-row query is inferred too: Reylo's cached analysis for this query records "cardinality":"maybeOne" with a plain-English reason — "GROUP BY returns one row per group, and none when nothing matches" — while my plain select ... order by id list query is "many", with its own reason. The reasoning is stored, inspectable, and in both cases right.
Errors point into the .sql file, and the schema is live
Rename a column inside the SQL — emial for email — and the error doesn't surface in TypeScript at all. It surfaces in the query file, at the column:
badsql.sql(1,12): error reylo6: no such column: emial
More important is the reverse direction. I renamed email to email2 in the database, leaving every source file untouched, and re-ran the check:
get_author.sql(1,22): error reylo6: no such column: u.email
list_users.sql(1,18): error reylo6: no such column: email
Both queries broke, at the right places, because the database changed. That is the whole pitch, working: there is no generated types file to regenerate and forget — the schema the checker sees is the schema that exists, every run. (Without --runExternalCode on the tsc command line, TypeScript 7.1 refuses the setup outright with a dedicated error, TS18068: content mappers require the flag.)
What "zero codegen" actually leaves on disk
The README's claim is "no generated files, no watcher", and in the sense that matters it's true: nothing is emitted into your source tree for you to keep in sync or accidentally edit. But the checker isn't magic — it writes a cache, .reylo/queries/, one JSON per query holding the analysis (columns, nullability, the rewritten SQL with positional parameters, even the pragma query it used to introspect my schema). Reylo also writes .reylo/.gitignore containing *, so its own cache never gets committed. That's the right way to do a cache: present, inspectable, and self-excluding.
The real cost is elsewhere, and it's the one to plan around: type-checking now needs a database. Remove the DB, the .env and the cache, and every .sql import errors at (1,1):
get_author.sql(1,1): error reylo4: No database URL: set DATABASE_URL in
the environment, .env.local or .env, or "direct_url" in .reylo.yaml.
The error is actionable and per-file, which is as good as this failure mode gets — but CI, a fresh clone, and your editor all need a dev database (or a SQLite file checked in / created in setup) before tsc can say anything about your queries. That's a fair trade for types that can't drift, and it's a trade you should know you're making.
My take
Content mappers are the real story here: tsc running a package's code to type a file format is a new extension point, and Reylo is a convincing first exhibit — the database becomes the source of truth at type-check time, nullability and cardinality included, and every one of my five attempts to sneak a wrong type past it failed with a precise, well-located error. The price is a beta compiler and a live database in every type-check environment. For a codebase whose SQL and schema actually change, I'd pay it.
Sources: github.com/colinhacks/reylo (README, repo created 2026-10-21), reylo.dev. Setup, the five misuse cases, the schema-rename test, the runtime results and all timings are my own runs (reylo 0.5.0, TypeScript 7.1.0-beta, Node v24.22.1, better-sqlite3 13.0.3).
Top comments (0)