В энтерпрайз-приложениях одной из фундаментальных причин инцидентов в продакшене является Type Drift (дрифт типов) — скрытое расхождение между реальной схемой реляционной базы данных (PostgreSQL, MySQL, SQLite) и TypeScript-декларациями в бэкенд-сервисе. Изменение DDL (например, добавление поля со значением по умолчанию, перевод колонки из NOT NULL в NULLABLE или смена типа с integer на bigint) при отсутствии строгой компиляторной связи немедленно приводит к рантайм-сбоям, которые невозможно выявить стандартным линтингом или модульными тестами без БД.
Исторически индустрия использовала два полярных подхода, каждый из которых имел серьезные архитектурные компромиссы:
.prisma. Однако наличие тяжелого бинарного C++ движка (Rust Query Engine), высокий оверхед по оперативной памяти (до сотен мегабайт в контейнерах) и невозможность гибкой статической типизации сложных сырых SQL-запросов (сложные оконные функции, CTE, множественные LEFT JOIN с группировками) делают этот подход непригодным для микросервисов с высокими требованиями к P99 Latency и расходу ресурсов.Современным стандартом для High-Load архитектур стала парадигма TypeScript-as-Schema (Type Providers), реализованная в Drizzle ORM и Kysely:
drizzle-kit generate);Select), создания (Insert) и обновления (Update);drizzle-zod, drizzle-typebox или drizzle-valibot);+-------------------------------------------------------------------------+
| SINGLE SOURCE OF TRUTH: ТИПИЗАЦИЯ БАЗ ДАННЫХ В TS |
| (Drizzle Schema / Database Contract Interface) |
+-------------------------------------------------------------------------+
| | |
| +-----------------------------+-----------------------------+ |
| | | | |
| v v v |
| [ DDL / Миграции ] [ Статические Типы ] [ 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 ] [ API Boundaries (RPC/REST) ] |
| Drizzle / Kysely: tRPC, Fastify, Hono: |
| Полный autocomplete SQL Гарантия валидности DTO |
| на уровне компилятора без дублирования схем |
+-------------------------------------------------------------------------+Ментальная модель Type Providers базируется на разделении жизненного цикла данных в реляционной СУБД на три независимые проекции:
Select): все колонки с дефолтными значениями (DEFAULT NOW(), SERIAL, GENERATED ALWAYS AS IDENTITY) гарантированно присутствуют и имеют конкретные типы (Date, number, string).Insert): колонки, имеющие дефолтные значения или автоинкремент, являются опциональными (id?: number, createdAt?: Date), в то время как поля без значений по умолчанию строго обязательны.Update): все поля опциональны, но типы обязаны удовлетворять ограничениям целостности колонок.В библиотеке Drizzle ORM типизация колонок вычисляется функционально через свойства метатипов в момент объявления схемы таблицы. Drizzle хранит типовую информацию о флагах hasDefault, notNull, isPrimaryKey внутри дженериков самой структуры таблицы.
В библиотеке Kysely архитектура опирается на контракт интерфейса Database. Типизация колонок, изменяющих форму при вставке и выборке, моделируется с помощью специального паттерна ColumnType<SelectType, InsertType, UpdateType> и обертки Generated<T>. Kysely является чистым компилятором запросов без рантайм-маппинга: он проверяет SQL-выражения исключительно на уровне типов TypeScript, компилируя их в чистые параметризованные SQL-строки без накладных расходов.
Объявите таблицы, foreign keys, индексы и типы колонок на TypeScript. Используйте .$type<T>() для нереляционных полей (JSONB, Array).
Запустите генератор Drizzle Kit для создания SQL-файлов миграций на основе разницы между текущим состоянием схемы и предыдущим снимком.
Создайте типы сущностей с помощью утилит $inferSelect и $inferInsert для использования в сервисах бизнес-логики.
Сгенерируйте схемы валидации входных данных эндпоинтов с обязательным вызовом .omit() для служебных и автогенерируемых колонок.
Добавьте запуск миграций и валидацию отсутствия git diff в CI, чтобы гарантировать 100% отсутствие Type Drift перед каждым релизом.
Почему в Drizzle ORM и Kysely критически важно разделять типы сущностей на Select ($inferSelect) и Insert ($inferInsert) модели?
numeric/decimal), где JavaScript number приводит к потере точности. Drizzle и Kysely форсируют использование строковых представлений или BigInt, предотвращая финансовые ошибки при вычислении комиссий и балансов.drizzle-zod. Разработчик изменяет ограничение длины строки в таблице БД, и валидатор HTTP-запросов автоматически начинает проверять новое ограничение без правок в кодовой базе API.OVER (PARTITION BY ...)), рекурсивными CTE и сложными JOIN. Kysely типизирует результирующий кортеж данных строго в соответствии с выбранными в .select() алиасами без накладных расходов на инстанцирование ORM-классов.kysely-codegen и проверяет через git diff, соответствуют ли сгенерированные типы текущему коммиту.Drizzle описывает схемы с помощью набора типизированных функций (pgTable, serial, text, integer, timestamp, jsonb). Каждое поле таблицы представляет собой инстанс класса колонки, сохраняющий сигнатуру типов в phantom types:
import { pgTable, serial, varchar, text, timestamp, boolean, jsonb, integer, index } from 'drizzle-orm/pg-core';
import { InferSelectModel, InferInsertModel, relations } from 'drizzle-orm';
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
import { z } from 'zod';
// 1. Декларация структуры таблицы (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. Статический инференс моделей через утилиты Drizzle
// Select модель: все поля обязательны, id и createdAt присутствуют как number и Date
export type UserSelect = InferSelectModel<typeof usersTable>;
// Альтернативный современный синтаксис: typeof usersTable.$inferSelect;
// Insert модель: поля с defaultNow() и serial становятся опциональными (?):
// { email: string; fullName: string; passwordHash: string; organizationId: number; metadata: {...}; id?: number; ... }
export type UserInsert = InferInsertModel<typeof usersTable>;
// Альтернативный современный синтаксис: typeof usersTable.$inferInsert;
// 3. Автоматическая генерация Runtime Zod-схем через drizzle-zod
// Базовая схема для валидации входящих данных при регистрации
export const rawUserInsertSchema = createInsertSchema(usersTable);
// Кастомизация схемы для публичного API (исключение служебных полей и строгая валидация)
export const apiRegisterUserSchema = createInsertSchema(usersTable, {
// Переопределение валидации email строгим парсером Zod
email: (schema) => schema.email({ message: 'Некорректный формат корпоративного email' }),
fullName: (schema) => schema.min(2).max(100),
// Валидация JSONB структуры
metadata: z.object({
role: z.enum(['admin', 'member']),
permissions: z.array(z.string()),
}),
}).omit({
// Исключаем поля, которые не должны приниматься от внешнего клиента
id: true,
createdAt: true,
updatedAt: true,
passwordHash: true, // Хэш генерируется на сервере, клиент присылает raw password!
}).extend({
password: z.string().min(12, 'Пароль должен содержать не менее 12 символов'),
});
export type ApiRegisterUserDTO = z.infer<typeof apiRegisterUserSchema>;
// Схема ответа API (санитизация: никогда не возвращаем passwordHash клиенту!)
export const apiUserResponseSchema = createSelectSchema(usersTable).omit({
passwordHash: true,
});
export type ApiUserResponseDTO = z.infer<typeof apiUserResponseSchema>;Разработчик объявляет таблицы на TypeScript со всеми ограничениями целостности, foreign keys, типами JSONB и дефолтными значениями.
Компилятор TypeScript автоматически выводит разные типы для чтения и записи сущностей через утилиты $inferSelect и $inferInsert.
Утилита createInsertSchema строит валидатор Zod/TypeBox для входящих DTO, гарантируя синхронизацию валидации с БД без дублирования кода.
Drizzle Query Builder проверяет имена колонок, типы параметров и возвращает строго типизированный массив объектов без runtime-маппинга.
Select Schema фильтрует конфиденциальные атрибуты (passwordHash) перед отправкой JSON-ответа потребителю API.
Kysely выбирают для систем с экстремальной нагрузкой, где ORM добавляет избыточные уровни абстракции, но требуется 100% статическая типизация SQL-запросов. В Kysely интерфейс базы данных описывается структурно:
import { Kysely, PostgresDialect, Generated, ColumnType, Selectable, Insertable, Updateable, sql } from 'kysely';
import { Pool } from 'pg';
// 1. Паттерн ColumnType<SelectType, InsertType, UpdateType>
// Позволяет задать разные типы для чтения, вставки и обновления колонки
export interface AccountsTable {
// Generated<T> эквивалентен ColumnType<T, T | undefined, never>
id: Generated<number>;
accountNumber: string;
// Баланс хранится в PostgreSQL как numeric(18, 4).
// Читается как string (во избежание потери точности IEEE 754 float),
// при вставке допускается string | number, при обновлении string | number
balance: ColumnType<string, string | number, string | number>;
currency: 'USD' | 'EUR' | 'GBP';
// Метаданные JSONB
settings: ColumnType<
{ notificationsEnabled: boolean; twoFactorRequired: boolean },
string, // При вставке сырой драйвер может требовать JSON-строку
string // При обновлении
>;
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>; // Нельзя обновлять сумму проведенной транзакции!
status: 'PENDING' | 'SETTLED' | 'REJECTED';
executedAt: ColumnType<Date, string | undefined, never>;
}
// 2. Мастер-интерфейс базы данных (Контракт для Kysely)
export interface DatabaseContract {
accounts: AccountsTable;
transactions: TransactionsTable;
}
// Хелперы Kysely для получения плоских типов:
export type Account = Selectable<AccountsTable>;
export type NewAccount = Insertable<AccountsTable>;
export type AccountUpdate = Updateable<AccountsTable>;
// 3. Инициализация строго типизированного клиента Kysely
export const db = new Kysely<DatabaseContract>({
dialect: new PostgresDialect({
pool: new Pool({ connectionString: process.env.DATABASE_URL }),
}),
});
// 4. Пример сложного типизированного запроса с агрегацией и JOIN
export async function getAccountAnalytics(accountId: number) {
// Kysely строго проверяет наличие таблиц 'accounts' и 'transactions',
// а также корректность условий соединения 'accounts.id' = 'transactions.accountId'
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',
// Сырые SQL-выражения типизируются через 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();
// Результирующий тип выводится автоматически:
// {
// accountId: number;
// accountNumber: string;
// currency: 'USD' | 'EUR' | 'GBP';
// totalTransactions: number;
// totalVolume: string;
// lastTransactionDate: Date;
// } | undefined
return result;
}Для предотвращения рассинхронизации между схемой СУБД и типами в репозитории на 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! Схема в TypeScript не соответствует выполненным SQL-миграциям."
echo "Пожалуйста, выполните 'pnpm run db:generate' локально и закоммитьте обновленные типы."
git diff src/db/schema.ts
exit 1
fi
- name: Run TypeScript Strict Compilation Check
run: pnpm run typecheckКакую угрозу безопасности несет прямой экспорт схемы createInsertSchema(usersTable) в публичный эндпоинт регистрации без вызова .omit()?
Использование типа InferSelectModel<typeof table> в качестве аргумента для функции создания записи приводит к тому, что TypeScript начинает требовать передачи автоинкрементных идентификаторов (id), дат генерации (createdAt) и колонок со значениями по умолчанию. Разработчики часто «обходят» это, принудительно ставя оператор as any или делая тип Partial<SelectModel>, что полностью разрушает безопасность типов:
// ОШИБКА: Использование Select модели для создания сущности
export async function createUserBad(data: InferSelectModel<typeof usersTable>) {
// Компилятор требует передать id, createdAt, updatedAt, даже если в БД на них стоят DEFAULT!
return await db.insert(usersTable).values(data).returning();
}
// ПРАВИЛЬНО: Использование специализированной модели Insert
export async function createUserCorrect(data: InferInsertModel<typeof usersTable>) {
// Поля id, createdAt, updatedAt опциональны, но обязательные поля (email, fullName) строго требуются
return await db.insert(usersTable).values(data).returning();
}Утилита createInsertSchema(table) послушно превращает все колонки таблицы в валидатор. Если вы передадите результат этой функции напрямую в валидатор роута Fastify/Hono/Express, злоумышленник сможет передать в теле запроса поля id, createdAt, isActive: true или role: 'superadmin', перезаписав критические атрибуты (Mass Assignment Vulnerability):
// ОШИБКА: Прямое использование сырой схемы в контроллере
const registerRouteSchema = createInsertSchema(usersTable);
app.post('/api/register', async (req, res) => {
// Уязвимость Mass Assignment: клиент может прислать { id: 1, role: 'superadmin' }!
const payload = registerRouteSchema.parse(req.body);
await db.insert(usersTable).values(payload);
});
// ПРАВИЛЬНО: Обязательное исключение внутренних полей через .omit()
const publicRegisterSchema = createInsertSchema(usersTable)
.omit({
id: true,
createdAt: true,
updatedAt: true,
isActive: true,
})
.extend({
email: z.string().email(),
});PostgreSQL тип jsonb при стандартном объявлении jsonb('data') инферится в TypeScript как тип unknown. Разработчики часто кастуют его к интерфейсу через as MyInterface непосредственно в бизнес-логике, что скрывает баги при изменении схемы данных внутри JSON:
// ОШИБКА: Нетипизированная колонка jsonb
export const auditLogs = pgTable('audit_logs', {
id: serial('id').primaryKey(),
payload: jsonb('payload'), // Тип в TypeScript: unknown
});
// ПРАВИЛЬНО: Строгая спецификация структуры через метод .$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(),
});PostgreSQL драйвер pg по умолчанию возвращает колонки типа numeric и decimal в виде строк (string), чтобы исключить потерю точности при работе с большими числами. Если в интерфейсе Kysely объявить колонку balance: number, компилятор пропустит код, но в рантайме сложение balance + 100 превратится в строковую конкатенацию "1500.50100":
// ОШИБКА: Объявление numeric колонки как number
export interface AccountsTableBad {
id: Generated<number>;
balance: number; // ЛОЖЬ: драйвер pg возвращает строку "1500.50"!
}
// ПРАВИЛЬНО: Честное указание типов через ColumnType
export interface AccountsTableCorrect {
id: Generated<number>;
// Читаем как string, для арифметики используем Big.js, Decimal.js или явно преобразуем
balance: ColumnType<string, string | number, string | number>;
}Паттерн Type Providers в современных TypeScript-бэкендах превращает реляционную схему базы данных в Единый источник правды (Single Source of Truth). Использование Drizzle ORM и Kysely позволяет:
Select) и создания (Insert) данных;drizzle-zod) без ручного дублирования полей;