Databases with Types
Expert · 20 min read · ▶ live playground · ✦ checkpoint
Database types let your query code see row shapes before it runs. They make server code calmer, but they do not let TypeScript inspect the real database after deployment.
A database is the system that stores application data outside your running process. A database schema is the list of tables, columns, and rules the database promises to enforce. A table is a named set of rows, and a row is one stored record in that table.
On the server, the best tools try to keep the database schema and TypeScript close together. That closeness is useful. It is not magic. Treat generated database types like a launch checklist: great when it matches the real launch pad, dangerous when one copy is old.
Drizzle keeps tables in TypeScript
An ORM is an object-relational mapper: a library that helps TypeScript code read and write database rows without hand-writing every SQL string. A query builder is a library API for composing database queries in code. Drizzle leans into TypeScript by describing tables in TypeScript code, then deriving row types from those table objects.
Shown as read-only reference code:
import { integer, pgTable, text } from "drizzle-orm/pg-core";
export const launches = pgTable("launches", {
id: text("id").primaryKey(),
ownerId: text("owner_id").notNull(),
name: text("name").notNull(),
karmaRequired: integer("karma_required").notNull(),
});
export type LaunchRow = typeof launches.$inferSelect;
export type NewLaunchRow = typeof launches.$inferInsert;LaunchRow is the shape you get back when selecting from the table. NewLaunchRow is the shape you send when inserting a new row. A generated id, a default timestamp, or a nullable column can make those promises differ.
Shown as read-only reference code:
import { launches, type LaunchRow, type NewLaunchRow } from "./schema.ts";
type LaunchInsertDb = {
insert(table: typeof launches): {
values(row: NewLaunchRow): {
returning(): Promise<LaunchRow[]>;
};
};
};
export async function createLaunch(db: LaunchInsertDb, row: NewLaunchRow): Promise<LaunchRow[]> {
return db.insert(launches).values(row).returning();
}Prisma generates the client
Prisma starts from a different center. A schema DSL is a small language made for one job. Prisma's schema file describes models, stored record types, the generator, and the datasource provider, the database kind Prisma targets. A Prisma Config file is Prisma's TypeScript tool config; it carries the connection URL. A generated client is TypeScript code a tool writes from that schema for typed queries.
Shown as read-only reference code:
datasource db {
provider = "postgresql"
}
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
model Launch {
id String @id
ownerId String
name String
karmaRequired Int
}Generated query code can use model-aware methods.
Shown as read-only reference code:
import type { PrismaClient } from "../generated/prisma/client";
type LaunchReader = Pick<PrismaClient, "launch">;
export async function loadLaunch(prisma: LaunchReader, id: string) {
return prisma.launch.findUnique({
where: { id },
select: {
id: true,
ownerId: true,
name: true,
karmaRequired: true,
},
});
}The practical difference is where the source of truth sits. With Drizzle, the table description lives in TypeScript. With Prisma, the model description lives in the Prisma schema, and the TypeScript client is generated from it. Both still depend on the real database matching the schema your code was built against.
Raw SQL is an honest boundary
Raw SQL is a database query string you write directly, such as select id, name from launches. It is useful when a query is clearer in SQL or needs a database feature your ORM does not expose well. It is also a boundary where TypeScript cannot infer the row shape by reading the live database.
Some tools offer typed SQL helpers. Those can be excellent. The honest fallback is still the same rule from unknown values: raw rows should start as unknown, then earn a trusted type through a parser, validator, or generated query type.
This playground uses local data instead of a real database. The stale parser is deliberately too weak, so you can see the failure mode.
→ Search launch for user_mina needs 10 karma
→ Search launch for user_mina needs undefined karma
Keep the schema moving with the code
Type drift is the gap where TypeScript believes one row shape while the database returns another. A migration can add a column, remove one, or rename one.
The safe loop is plain:
- Change the Drizzle table or Prisma model.
- Create the matching migration.
- Apply that migration to the databases your app will query.
- Regenerate types or clients when the tool requires it.
- Run type checks and tests against the new shape.
That order matters because the generated type is not the database. It is a promise made from a schema file. When those disagree, TypeScript can only check the promise it can see.
For raw SQL, make the boundary visible in code. Accept unknown rows, validate the fields you actually use, and return a typed result only after validation. That keeps the receipt honest.
Next, the course leaves servers and moves into command-line tools. The same boundary habits apply when your program reads flags, files, and process input.
Checkpoint
Answer all three to mark this lesson complete