En aplicaciones empresariales, una de las causas principales de incidentes en producción es el Type Drift (deriva de tipos): la divergencia silenciosa entre el esquema relacional real de la base de datos (PostgreSQL, MySQL, SQLite) y las declaraciones de TypeScript en los servicios de backend. Modificar el DDL (por ejemplo, añadir una columna con valor predeterminado, cambiar de NOT NULL a NULLABLE o ampliar un integer a bigint) sin un contrato enforced por el compilador genera fallos en tiempo de ejecución invisibles para linters convencionales o pruebas unitarias desvinculadas de la base de datos.
Históricamente, la industria oscilaba entre dos alternativas extremas con grandes sacrificios arquitectónicos:
.prisma. Sin embargo, su motor binario de consultas en C++ (Rust Query Engine), el alto consumo de memoria (cientos de megabytes en contenedores limitados) y la incapacidad para tipar con flexibilidad consultas SQL complejas (funciones de ventana, CTE recursivos, múltiples LEFT JOIN agrupados) lo hacen inadecuado para microservicios con requisitos estrictos de P99 Latency y consumo de recursos.El estándar moderno para arquitecturas de alta exigencia es el paradigma TypeScript-as-Schema (Type Providers), materializado por Drizzle ORM y Kysely:
drizzle-kit generate);Select), inserción (Insert) y actualización (Update);drizzle-zod, drizzle-typebox o drizzle-valibot);+-------------------------------------------------------------------------+
| SINGLE SOURCE OF TRUTH: CONTRATOS DE BD EN TYPESCRIPT |
| (Drizzle Schema / Database Contract Interface) |
+-------------------------------------------------------------------------+
| | |
| +-----------------------------+-----------------------------+ |
| | | | |
| v v v |
| [ DDL / Migraciones ] [ Tipos Estáticos Inferidos] [ Validación Runtime]|
| Drizzle Kit / SQL Select / Insert / Update drizzle-zod / |
| CREATE TABLE ... InferSelectModel<T> createInsertSchema |
| ALTER TABLE ... Generated<T> Zod / TypeBox DTO |
| | |
| +---------------+---------------+ |
| | | |
| v v |
| [ Type-Safe Query Builder ] [ Fronteras de API (RPC/REST) ] |
| Drizzle / Kysely: tRPC, Fastify, Hono: |
| Autocompletado y chequeo SQL Validez de DTO garantizada |
| en tiempo de compilación sin duplicar esquemas |
+-------------------------------------------------------------------------+El modelo mental de Type Providers descompone el ciclo de vida de un registro en la base de datos relacional en tres proyecciones independientes:
Select): todas las columnas con valores predeterminados (DEFAULT NOW(), SERIAL, GENERATED ALWAYS AS IDENTITY) están garantizadas y poseen tipos concretos (Date, number, string).Insert): las columnas con valores predeterminados o autoincrementales se vuelven opcionales (id?: number, createdAt?: Date), mientras que los campos sin valor por defecto son obligatorios.Update): todos los campos son opcionales, pero los tipos suministrados deben respetar las restricciones de integridad de cada columna.En Drizzle ORM, el tipado de las columnas se calcula funcionalmente a partir de la definición del esquema de la tabla. Drizzle almacena indicadores de metadatos como hasDefault, notNull o isPrimaryKey dentro de los parámetros genéricos de la estructura de la tabla.
En Kysely, la arquitectura reposa sobre el contrato de la interfaz Database. Las columnas cuya forma difiere entre inserción y lectura se modelan mediante el patrón ColumnType<SelectType, InsertType, UpdateType> y la envoltura Generated<T>. Kysely actúa como un compilador puro de consultas sin mapeo en tiempo de ejecución: evalúa expresiones SQL exclusivamente a nivel de tipos de TypeScript, compilándolas a cadenas SQL parametrizadas sin coste de procesamiento.
numeric/decimal), donde el tipo number de JavaScript genera pérdida de precisión bajo la norma IEEE 754. Drizzle y Kysely obligan al uso de cadenas o BigInt, previniendo errores de cálculo en libros contables.drizzle-zod. Al alterar una restricción de columna, la validación HTTP se sincroniza de inmediato sin tocar el código del API.OVER (PARTITION BY ...)), CTEs recursivas y joins agrupados. Kysely tipa el resultado atendiendo estrictamente a los alias seleccionados sin sobrecarga de instanciación de clases.kysely-codegen y comprueba con git diff que los tipos generados coincidan con el repositorio.Drizzle define esquemas de tabla mediante funciones tipadas (pgTable, serial, varchar, timestamp, jsonb). Cada columna almacena metadatos y firmas en phantom types:
import { pgTable, serial, varchar, text, timestamp, boolean, jsonb, integer, index } from 'drizzle-orm/pg-core';
import { InferSelectModel, InferInsertModel } from 'drizzle-orm';
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
import { z } from 'zod';
// 1. Declaración declarativa del esquema (Single Source of Truth)
export const organizationsTable = pgTable('organizations', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 256 }).notNull(),
slug: varchar('slug', { length: 128 }).notNull().unique(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
export const usersTable = pgTable('users', {
id: serial('id').primaryKey(),
organizationId: integer('organization_id')
.notNull()
.references(() => organizationsTable.id, { onDelete: 'cascade' }),
email: varchar('email', { length: 320 }).notNull().unique(),
fullName: varchar('full_name', { length: 256 }).notNull(),
passwordHash: text('password_hash').notNull(),
isActive: boolean('is_active').default(true).notNull(),
metadata: jsonb('metadata').$type<{ role: 'admin' | 'member'; permissions: string[] }>().notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => [
index('users_org_idx').on(table.organizationId),
index('users_email_idx').on(table.email),
]);
// 2. Inferencia estática de modelos mediante utilidades de Drizzle
// Modelo Select: todos los campos son requeridos; id y createdAt se infieren como number y Date
export type UserSelect = InferSelectModel<typeof usersTable>;
// Sintaxis moderna equivalente: typeof usersTable.$inferSelect;
// Modelo Insert: columnas con defaultNow() y serial pasan a ser opcionales (?):
// { email: string; fullName: string; passwordHash: string; organizationId: number; metadata: {...}; id?: number; ... }
export type UserInsert = InferInsertModel<typeof usersTable>;
// Sintaxis moderna equivalente: typeof usersTable.$inferInsert;
// 3. Generación automática de esquemas Zod con drizzle-zod
// Esquema base sin modificaciones
export const rawUserInsertSchema = createInsertSchema(usersTable);
// Esquema personalizado para consumo público (exclusión de campos internos y validación estricta)
export const apiRegisterUserSchema = createInsertSchema(usersTable, {
email: (schema) => schema.email({ message: 'Formato de correo corporativo no válido' }),
fullName: (schema) => schema.min(2).max(100),
metadata: z.object({
role: z.enum(['admin', 'member']),
permissions: z.array(z.string()),
}),
}).omit({
id: true,
createdAt: true,
updatedAt: true,
passwordHash: true, // ¡Se genera en el servidor; el cliente envía contraseña sin hashear!
}).extend({
password: z.string().min(12, 'La contraseña debe contener al menos 12 caracteres'),
});
export type ApiRegisterUserDTO = z.infer<typeof apiRegisterUserSchema>;
// Esquema de respuesta de la API (saneamiento: ¡nunca devolver passwordHash al cliente!)
export const apiUserResponseSchema = createSelectSchema(usersTable).omit({
passwordHash: true,
});
export type ApiUserResponseDTO = z.infer<typeof apiUserResponseSchema>;El desarrollador define tablas en TypeScript con restricciones, claves foráneas, tipos JSONB y valores por defecto.
El compilador genera modelos diferenciados para operaciones de lectura e inserción mediante $inferSelect y $inferInsert.
createInsertSchema construye validadores Zod/TypeBox para DTOs entrantes, manteniendo sincronizada la API con la BD.
Drizzle Query Builder comprueba nombres de columna y parámetros en compilación, devolviendo tuplas sin sobrecarga de ORM.
Select Schema filtra propiedades sensibles (passwordHash) antes de retornar la carga JSON al cliente HTTP.
Kysely es la opción idónea en sistemas de alta exigencia donde los ORM tradicionales añaden capas de abstracción innecesarias, pero se requiere seguridad absoluta en tiempo de compilación. En Kysely, la base de datos se describe estructuralmente mediante una interfaz:
import { Kysely, PostgresDialect, Generated, ColumnType, Selectable, Insertable, Updateable, sql } from 'kysely';
import { Pool } from 'pg';
// 1. Patrón ColumnType<SelectType, InsertType, UpdateType>
// Permite especificar tipos independientes para SELECT, INSERT y UPDATE sobre una columna
export interface AccountsTable {
// Generated<T> equivale a ColumnType<T, T | undefined, never>
id: Generated<number>;
accountNumber: string;
// Guardado en PostgreSQL como numeric(18, 4).
// Se lee como string para evitar pérdida de precisión IEEE 754; admite string | number al escribir
balance: ColumnType<string, string | number, string | number>;
currency: 'USD' | 'EUR' | 'GBP';
// Metadatos estructurados en JSONB
settings: ColumnType<
{ notificationsEnabled: boolean; twoFactorRequired: boolean },
string, // El driver puede requerir cadena serializada en inserción
string // Cadena en actualización
>;
createdAt: ColumnType<Date, string | undefined, never>;
updatedAt: ColumnType<Date, string | undefined, string>;
}
export interface TransactionsTable {
id: Generated<string>; // UUID v4
accountId: number;
amount: ColumnType<string, string | number, never>; // Inmutabilidad: ¡el importe de una transacción asentada no se puede modificar!
status: 'PENDING' | 'SETTLED' | 'REJECTED';
executedAt: ColumnType<Date, string | undefined, never>;
}
// 2. Interfaz principal de la base de datos (Contrato para Kysely)
export interface DatabaseContract {
accounts: AccountsTable;
transactions: TransactionsTable;
}
// Utilidades de Kysely para extracción de tipos planos
export type Account = Selectable<AccountsTable>;
export type NewAccount = Insertable<AccountsTable>;
export type AccountUpdate = Updateable<AccountsTable>;
// 3. Inicialización del cliente fuertemente tipado de Kysely
export const db = new Kysely<DatabaseContract>({
dialect: new PostgresDialect({
pool: new Pool({ connectionString: process.env.DATABASE_URL }),
}),
});
// 4. Consulta compleja con agregación analítica y JOIN
export async function getAccountAnalytics(accountId: number) {
// Kysely valida tablas, existencia de columnas y condiciones de unión en compilación
const result = await db
.selectFrom('accounts')
.innerJoin('transactions', 'transactions.accountId', 'accounts.id')
.where('accounts.id', '=', accountId)
.where('transactions.status', '=', 'SETTLED')
.select([
'accounts.id as accountId',
'accounts.accountNumber',
'accounts.currency',
// Expresiones SQL puras tipadas mediante sql<T>`...`
sql<number>`cast(count(transactions.id) as int)`.as('totalTransactions'),
sql<string>`cast(coalesce(sum(cast(transactions.amount as numeric)), 0) as text)`.as('totalVolume'),
sql<Date>`max(transactions.executed_at)`.as('lastTransactionDate'),
])
.groupBy(['accounts.id', 'accounts.accountNumber', 'accounts.currency'])
.executeTakeFirst();
// Tipo inferido automáticamente:
// {
// accountId: number;
// accountNumber: string;
// currency: 'USD' | 'EUR' | 'GBP';
// totalTransactions: number;
// totalVolume: string;
// lastTransactionDate: Date;
// } | undefined
return result;
}Para impedir discrepancias entre las migraciones de PostgreSQL y las definiciones de TypeScript en el repositorio, se implementa esta comprobación en CI:
name: Database Type Drift Verification
on:
pull_request:
branches: [main, master]
paths:
- 'src/db/**'
- 'migrations/**'
- 'package.json'
jobs:
verify-type-drift:
name: Check SQL Migrations vs TypeScript Types
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
POSTGRES_DB: test_db
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Node.js & pnpm
uses: pnpm/action-setup@v3
with:
version: 9
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Run Database Migrations on Temporary Postgres
env:
DATABASE_URL: postgres://test_user:test_password@localhost:5432/test_db
run: pnpm run db:migrate
- name: Generate TypeScript Schemas from Live DB (Introspection)
env:
DATABASE_URL: postgres://test_user:test_password@localhost:5432/test_db
run: pnpm run db:introspect-check
- name: Verify No Git Diff (Fail if Type Drift Detected)
run: |
git status --porcelain
if [ -n "$(git status --porcelain src/db/schema.ts)" ]; then
echo "::error::¡Type Drift detectado! El esquema en TypeScript discrepa de las migraciones SQL aplicadas."
echo "Ejecute 'pnpm run db:generate' en local y confirme los tipos actualizados en git."
git diff src/db/schema.ts
exit 1
fi
- name: Run TypeScript Strict Compilation Check
run: pnpm run typecheckUtilizar InferSelectModel<typeof table> como tipo del argumento en funciones de inserción obliga a TypeScript a requerir identificadores autoincrementales (id), marcas de tiempo (createdAt) y campos con valor por defecto. Es habitual que los desarrolladores recurran a as any o Partial<SelectModel>, destruyendo la seguridad de tipos:
// ❌ INCORRECTO: Uso del modelo Select para insertar registros
export async function createUserBad(data: InferSelectModel<typeof usersTable>) {
// Obliga a pasar id, createdAt y updatedAt aunque la base de datos tenga DEFAULT!
return await db.insert(usersTable).values(data).returning();
}
// ✅ CORRECTO: Uso del modelo especializado Insert
export async function createUserCorrect(data: InferInsertModel<typeof usersTable>) {
// id, createdAt y updatedAt son opcionales; los campos obligatorios se exigen estrictamente
return await db.insert(usersTable).values(data).returning();
}La función createInsertSchema(table) traduce directamente todas las columnas de la tabla en un validador. Exponer este esquema sin saneamiento en rutas públicas permite que un usuario malicioso envíe campos como id, createdAt, isActive: true o role: 'superadmin', provocando una vulnerabilidad de asignación masiva:
// ❌ VULNERABLE: Exposición directa del esquema crudo de la tabla
const registerRouteSchema = createInsertSchema(usersTable);
app.post('/api/register', async (req, res) => {
// Vulnerabilidad de Mass Assignment: ¡El cliente puede inyectar { id: 1, role: 'superadmin' }!
const payload = registerRouteSchema.parse(req.body);
await db.insert(usersTable).values(payload);
});
// ✅ SEGURO: Exclusión explícita de propiedades protegidas con .omit()
const publicRegisterSchema = createInsertSchema(usersTable)
.omit({
id: true,
createdAt: true,
updatedAt: true,
isActive: true,
})
.extend({
email: z.string().email(),
});En PostgreSQL, la declaración estándar jsonb('data') se infiere en TypeScript como unknown. Forzar el tipo mediante as MyInterface dentro de los controladores oculta errores al modificarse la estructura del JSON:
// ❌ NO TIPADO: Columna JSONB resuelta a unknown
export const auditLogs = pgTable('audit_logs', {
id: serial('id').primaryKey(),
payload: jsonb('payload'), // Tipo en TypeScript: unknown
});
// ✅ TIPADO ESTRICTO: Forzar estructura mediante el método .$type<T>()
export interface AuditPayload {
ipAddress: string;
userAgent: string;
eventType: 'LOGIN' | 'LOGOUT' | 'PASSWORD_CHANGE';
diff?: Record<string, { before: unknown; after: unknown }>;
}
export const auditLogsStrict = pgTable('audit_logs', {
id: serial('id').primaryKey(),
payload: jsonb('payload').$type<AuditPayload>().notNull(),
});El controlador de PostgreSQL para Node.js pg retorna las columnas numeric y decimal como cadenas (string) para salvaguardar la precisión más allá de los límites de IEEE 754. Si se declara balance: number en Kysely, el código compilará, pero en ejecución una suma como balance + 100 producirá una concatenación de cadenas ("1500.50100"):
// ❌ INSEGURO: Declarar columnas numéricas como number de JavaScript
export interface AccountsTableBad {
id: Generated<number>;
balance: number; // ERROR EN TIEMPO DE EJECUCIÓN: ¡el driver pg devuelve "1500.50"!
}
// ✅ CORRECTO: Tipos fidedignos con el patrón ColumnType
export interface AccountsTableCorrect {
id: Generated<number>;
// Se lee como string; las operaciones se realizan con Decimal.js o conversión explícita
balance: ColumnType<string, string | number, string | number>;
}Declarar tablas, claves foráneas, índices y restricciones en TypeScript. Emplear .$type<T>() para campos JSONB.
Ejecutar Drizzle Kit CLI para crear archivos de migración SQL deterministas a partir de la comparación de esquemas.
Exportar modelos Select e Insert con $inferSelect y $inferInsert para su utilización en los servicios de negocio.
Generar esquemas de validación de endpoints aplicando .omit() sobre columnas de sistema o autogeneradas.
Ejecutar migraciones y validar la ausencia de cambios en git diff en CI para prevenir por completo el Type Drift antes de cada despliegue.
El paradigma Type Providers convierte el esquema relacional en la Única fuente de verdad (Single Source of Truth, SSOT) de las arquitecturas modernas en TypeScript. Adoptar Drizzle ORM y Kysely permite a los equipos:
Select) e inserción (Insert);drizzle-zod) sin redundancia de campos;