Full-Stack

Type-safe end to end: the Prisma + TypeScript patterns I trust

How I keep types flowing from the database to the button label — and catch the breakage at compile time, not in support tickets.

The dream of "full-stack TypeScript" is that a column rename in the database lights up red squiggles all the way to the React component that displayed it. The reality, for most codebases, is a pile of hand-written interfaces that drift out of sync the moment someone's in a hurry. The difference is a few patterns — none of them clever, all of them about having one source of truth.

Infer types from queries — never redeclare them

The original sin is writing an interface Gym by hand to mirror a database table. Now you have two definitions of the same thing, and nothing forces them to agree. Prisma already knows the exact shape of every query, including its relations. Ask it for that type instead of re-typing it.

data/gym.ts
// One source of truth: infer the type from the query, don't redeclare it.
import { Prisma } from "@prisma/client";

const gymWithMembers = Prisma.validator<Prisma.GymDefaultArgs>()({
  include: { members: true },
});

// This type updates itself when the schema or the query changes.
export type GymWithMembers = Prisma.GymGetPayload<typeof gymWithMembers>;

export async function getGym(id: string): Promise<GymWithMembers> {
  return db.gym.findUniqueOrThrow({ where: { id }, ...gymWithMembers });
}

Add a relation to the query and GymWithMembers grows a field automatically. Remove a column from the schema and every consumer that read it turns red. You didn't maintain a type — you derived one, and derivation can't drift.

The rule: types flow in one direction — schema → query → component. Anywhere you hand-write a type that restates something the layer below already knows, you've created a place for the two to disagree. Infer instead.

Validate at the boundary, infer everywhere inside

The one place you can't infer is where untrusted data enters — a request body, a form, a query param. There, parse with a schema validator (I use Zod) and let the validated result's type flow inward. You write the shape once, at the edge; the validator gives you both the runtime check and the static type from the same definition. Inside the boundary, everything is inferred from there.

Make impossible states unrepresentable

A loading: boolean next to a data: T | null and an error: string | null lets you express nonsense — loading and errored, done with no data. A discriminated union doesn't:

  • Model state as a union, not a bag of flags. { status: 'loading' } | { status: 'ok', data: T } | { status: 'error', message: string } — now the compiler forces you to handle each case, and the impossible combinations literally can't be typed.
  • Prefer `unknown` over `any` at every boundary. any silently switches off the type checker for everything it touches; unknown forces a narrowing step. The friction is the feature.
  • Let exhaustiveness checks catch new cases. A default: assertNever(x) in a switch turns "someone added an enum value and forgot a branch" from a production bug into a compile error.

Why this pays off

None of these patterns are about elegance for its own sake. They move a whole category of bug — the "this field is suddenly undefined in production" kind — from runtime, where a user finds it, to compile time, where you find it before the commit lands. On a solo project that's the difference between shipping and firefighting.

Every hand-written type that restates the database is a future bug with a delay timer. Infer the type, and the timer never starts.
More articlesBuilding something? Let's talk