Build Your Own Bedrock · Part 12
T2: never trust a database row
Your database feels like part of your app. It isn't. The schema is mutated by migrations, the data by every script and admin console that ever touched production. The TypeScript types you give your query builder describe what you believe the schema is — the compiler never checks them against Postgres. So bedrock treats the database like any other wall from Part 7: primitives go in, and nothing comes out without being decoded. That's the T2 layer.
Two types per table
The Kysely schema describes the raw SQL surface — primitives only, because that's all a database can actually store:
// Api/src/Database.ts
export type Schema = {
user: UserTable
refresh_token: RefreshTokenTable
}
type UserTable = {
id: string
email: string
name: string
password: string
isDeleted: boolean
updatedAt: Date
createdAt: Date
} But no handler ever sees a UserTable. What circulates in the app is UserRow — same columns, real types. Each row module exports the type and the decoder that proves it:
// Api/src/Database/UserRow.ts
export type UserRow = {
id: UserID
email: Email
name: Name
password: string // hashed password
isDeleted: boolean
updatedAt: Timestamp
createdAt: Timestamp
}
export const userRowDecoder: JD.Decoder<UserRow> = JD.object({
id: userIDDecoder,
email: emailDecoder,
name: nameDecoder,
password: JD.string,
isDeleted: JD.boolean,
updatedAt: timestampJSDateDecoder,
createdAt: timestampJSDateDecoder,
}) UserID, Email, Name, Timestamp — opaque T1 types from Part 5. If a migration slips a malformed email into the table, the decoder rejects the row at the query site, loudly, instead of letting it wander the codebase as a trusted Email.
Unwrap in, verify out
Every query function shows the same symmetry. Opaque values are .unwrap()ed into SQL — the greppable exit from the type system — and every returning row passes through userRowDecoder.verify on the way back:
// Api/src/Database/UserRow.ts
export async function getByEmail(email: Email): Promise<Maybe<UserRow>> {
return db
.selectFrom(tableName)
.selectAll()
.where("email", "=", email.unwrap())
.where("isDeleted", "=", false)
.executeTakeFirst()
.then((row) => (row == null ? null : userRowDecoder.verify(row)))
.catch((e) => {
Logger.error(`#${tableName}.getByEmail error ${e}`)
throw e
})
} Three deliberate choices here. The lookup functions return Maybe<UserRow> — an absent user is a normal domain outcome, so it's a value, not an exception. The throw in the catch is not a violation of never-throw: a dead connection or a failed decode is infrastructure failure, not domain logic, so it's logged and rethrown to surface as the 500-with-errorID channel from Part 9. And note the signature takes an Email, not a string — you cannot even ask this module about an invalid email.
Even aggregates get decoded
It's tempting to trust COUNT(*). Bedrock doesn't — Postgres returns bigint counts as strings, and drivers disagree on the details. The aggregate crosses the same wall as everything else and comes out as a Nat, the can't-be-negative number from Part 6:
// Api/src/Database/UserRow.ts
export async function count(): Promise<Nat> {
return db
.selectFrom(tableName)
.select([(b) => b.fn.count("id").as("total")])
.executeTakeFirst()
.then((r) => natDecoder.verify(Number(r?.total)))
.catch((e) => {
Logger.error(`#${tableName}.count error ${e}`)
throw e
})
} The named back door
Tests need to insert rows in states the real create function would refuse — old timestamps, deleted users. Rather than let tests bypass the module, the module exports the bypass and names it what it is: unsafeCreate(row: UserRow), which skips the domain rules but still round-trips its result through userRowDecoder.verify. Like throwIfNull in Part 3, the escape hatch exists — once, named unsafe, and greppable in seconds. A code reviewer who sees it outside a test file doesn't need context to object.
In your language
Rust's sqlx checks queries against a live schema at compile time — and its rows still deserialize into structs, where newtype wrappers with TryFrom play the role of the opaque fields. Haskell's persistent maps rows to typed entities and lets smart constructors guard the fields. In Kotlin, map JDBC or Exposed results into data classes whose init blocks validate. Go's sql.Scan into a struct with unexported fields, returned only by a constructor that checks invariants, is the same wall. The universal rule: the type your handlers consume must be one your database driver cannot construct — only your decoder can.