Full-Stack

Tipado de extremo a extremo: los patrones de Prisma + TypeScript en los que confío

Cómo mantengo los tipos fluyendo desde la base de datos hasta la etiqueta del botón — y atrapo los errores en tiempo de compilación, no en tickets de soporte.

El sueño del "TypeScript full-stack" es que renombrar una columna en la base de datos encienda subrayados rojos hasta el componente de React que la mostraba. La realidad, en la mayoría de las bases de código, es una pila de interfaces escritas a mano que se desincronizan en cuanto alguien va con prisa. La diferencia son unos pocos patrones — ninguno ingenioso, todos sobre tener una sola fuente de verdad.

Infiere los tipos de las consultas — nunca los redeclares

El pecado original es escribir una interface Gym a mano para reflejar una tabla. Ahora tienes dos definiciones de lo mismo, y nada las obliga a coincidir. Prisma ya conoce la forma exacta de cada consulta, incluidas sus relaciones. Pídele ese tipo en vez de volver a escribirlo.

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 });
}

Añade una relación a la consulta y GymWithMembers gana un campo automáticamente. Quita una columna del esquema y cada consumidor que la leía se pone rojo. No mantuviste un tipo — lo derivaste, y una derivación no se puede desincronizar.

La regla: los tipos fluyen en una dirección — esquema → consulta → componente. Donde sea que escribas a mano un tipo que repite algo que la capa de abajo ya sabe, has creado un lugar para que ambos discrepen. Mejor infiere.

Valida en el límite, infiere en todo el interior

El único lugar donde no puedes inferir es donde entran datos no confiables — un body de petición, un formulario, un query param. Ahí, parsea con un validador de esquemas (yo uso Zod) y deja que el tipo del resultado validado fluya hacia adentro. Escribes la forma una vez, en el borde; el validador te da tanto la verificación en runtime como el tipo estático desde la misma definición. Dentro del límite, todo se infiere desde ahí.

Haz que los estados imposibles no se puedan representar

Un loading: boolean junto a un data: T | null y un error: string | null te deja expresar sinsentidos — cargando y con error, listo sin datos. Una unión discriminada no:

  • Modela el estado como una unión, no como una bolsa de flags. { status: 'loading' } | { status: 'ok', data: T } | { status: 'error', message: string } — ahora el compilador te obliga a manejar cada caso, y las combinaciones imposibles literalmente no se pueden tipar.
  • Prefiere `unknown` sobre `any` en cada límite. any apaga en silencio el verificador de tipos para todo lo que toca; unknown obliga a un paso de estrechamiento. La fricción es la función.
  • Deja que las verificaciones de exhaustividad atrapen casos nuevos. Un default: assertNever(x) en un switch convierte "alguien añadió un valor al enum y olvidó una rama" de un bug en producción a un error de compilación.

Por qué vale la pena

Ninguno de estos patrones busca elegancia por sí misma. Mueven toda una categoría de bug — el tipo "este campo de repente es undefined en producción" — desde el runtime, donde lo encuentra un usuario, al tiempo de compilación, donde lo encuentras antes de que el commit aterrice. En un proyecto en solitario eso es la diferencia entre lanzar y apagar incendios.

Cada tipo escrito a mano que repite la base de datos es un bug futuro con temporizador. Infiere el tipo, y el temporizador nunca arranca.
Más artículos¿Construyendo algo? Hablemos