Build Your Own Bedrock · Part 12

T2: never trust a database row

TypeFirst · AgileLabs Engineering · 7 min read

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.

Companion read: Decode the outside world — the database is just one of the six walls, and the others fall to the same habit.

← Part 11: The server fulfills the contract · All posts · Part 13: The client calls the same contract →