> Every UQL docs page, as Markdown: https://uql-orm.dev/llms.txt
> The same docs over MCP: https://uql-orm.dev/mcp
> Before writing UQL code, read the skill: https://uql-orm.dev/.well-known/agent-skills/uql-orm/SKILL.md

# In search of the type-safest ORM

> Reading a column your query never selected is undefined at runtime and silent at compile time. Why I resisted fixing it, and what changed my mind.

Source: https://uql-orm.dev/blog/in-search-of-the-type-safest-orm

The [type-safety benchmark](https://github.com/rogerpadilla/ts-orm-benchmark#type-safety) writes ten ordinary mistakes in six ORMs’ own APIs and compiles them, so “type-safe” becomes a count instead of a word on a homepage. UQL caught nine. This is about the tenth, and why I put off fixing it.

## The mistake

```ts
const [user] = await pool.findMany(User, {
  $select: { id: true, name: true },
});

user.email; // undefined at runtime. Every time.
```

`email` was never selected, so the property is not there. Nothing objected, because the result was typed as the whole entity whatever the query asked for. The `undefined` travels into a response body or the next update, and turns up days later as a null column nobody wrote.

## Why I left it alone, for a while

I knew how to fix it. I didn’t want to, and not because of the type machinery.

Narrowing takes something away. Once a row is `{ id, name }` instead of `User`, it stops being the loose bag application code likes to pass around: hand it to a helper typed `(user: User) => ...` and it’s rejected. I didn’t want to trade a daily convenience for a compile error on a mistake I rarely make.

So I built it, upgraded every project I maintain, work ones included, and counted the damage. There was none: [Variability](https://variability.ai) alone has 200+ find calls across 25+ files, and it compiled clean, unchanged.

The pattern I was protecting turned out to be one I don’t actually write. Code that tops up a row starts from a full entity, because it needs the fields; code that projects ships the row straight out, to an API response, a list, a picker.

---

## Why UQL still catches the typo

To shape the row, the query has to be captured as a type parameter, and that is where TypeScript changed. TypeScript 6.0 stopped reporting unknown keys on a literal checked against a captured map. Prisma’s and Drizzle’s projections are exactly that shape, which is why a misspelled column [compiles for both today](https://uql-orm.dev/type-safety.md), and why they sit at nine and eight rather than ten.

UQL captures the field *names* instead of the map holding them, so the compiler answers a different question:

```ts
$select: { id: true, emial: true }

// against a captured map:  is 'emial' a known key?   - no longer reported
// against captured names:  'id' | 'emial' extends FieldKey<User>?   - 'emial' is not a field
```

The second is a constraint, and constraints are still checked. `$where`, `$sort` and each populated relation aren’t captured at all, so they keep the checks they always had.

---

## What does it look like now?

The row is what you asked for, and the compiler knows it:

```ts
const [user] = await pool.findMany(User, {
  $select: { name: true },
  $populate: { posts: { $select: { id: true, title: true } } },
});

user.name; // string
user.posts.map((post) => post.title); // a list, empty at worst, no guard needed
user.email; // compile error: not selected
user.profile; // compile error: not populated
```

A query with no projection still returns the whole entity, so nothing changes where you didn’t ask for anything. Where a helper has to take a projected row, name the shape instead of widening the query:

```ts
import type { QueryFindResult } from 'uql-orm';

type UserCard = QueryFindResult<User, 'id' | 'name'>;
```

That is ten of ten on the [benchmark](https://uql-orm.dev/type-safety.md), the only entry with no red mark.

[![](/type-safety-poster.webp)](/type-safety.mp4)

Each ORM's ten mistakes, underlined by the compiler as the tab changes. [Type it yourself](https://uql-orm.dev/type-safety.md).

Ten probes can’t separate six ORMs the way a microsecond can, and I chose the ten. So the claim is the narrow one: these are ten mistakes people make on an ordinary afternoon, and this is the ORM that refuses all of them. Every probe is in [type-safety/](https://github.com/rogerpadilla/ts-orm-benchmark/tree/main/type-safety), compiled as written and again with each mistake corrected. Clone it and check.

And if you’re where I was, weighing a check you suspect you won’t need against a convenience you’re sure you use: upgrade a branch and count the damage. It’s a cheaper argument than the one I had with myself.

---

---

*[UQL](https://uql-orm.dev/index.md) is a JSON-native TypeScript ORM for Node.js and Bun. Supports PostgreSQL, PGlite, MySQL, MariaDB, MSSQL, SQLite, CockroachDB, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.*
