В enterprise-застосунках однією з фундаментальних причин інцидентів у продакшені є 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-рядки без накладних витрат.
numeric/decimal, де використання типу number у JavaScript спричиняє втрату точності за стандартом IEEE 754. Drizzle і Kysely змушують використовувати рядки або BigInt, запобігаючи фінансовим помилкам.drizzle-zod. Зміна обмежень на колонці миттєво оновлює правила валідації запитів без ручного редагування коду API.OVER (PARTITION BY ...)), рекурсивними CTE та згрупованими join. Kysely типізує результат виключно за обраними аліасами без накладних витрат на інстанціювання класів.kysely-codegen і перевіряє через git diff, чи відповідають згенеровані файли поточному коміту.Drizzle описує таблиці за допомогою типізованих функцій (pgTable, serial, varchar, timestamp, jsonb). Кожне поле є екземпляром класу колонки, що зберігає сигнатуру типів у 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. Декларація схеми таблиці (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. Автоматична генерація схем Zod через drizzle-zod
// Базова схема для валідації вхідних даних
export const rawUserInsertSchema = createInsertSchema(usersTable);
// Кастомізація схеми для публічного API (вилучення внутрішніх полей і строга валідація)
export const apiRegisterUserSchema = createInsertSchema(usersTable, {
email: (schema) => schema.email({ message: 'Некоректний формат корпоративної пошти' }),
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, // Генерується на сервері; клієнт надсилає сирий пароль!
}).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, синхронізуючи межі API з БД без дублювання коду.
Drizzle Query Builder перевіряє назви колонок і параметри під час компіляції, повертаючи типізовані масиви без ORM-оверхеду.
Select Schema відфільтровує конфіденційні атрибути (passwordHash) перед поверненням JSON-відповіді клієнту.
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>
// Дозволяє задати окремі статичні типи для SELECT, INSERT та UPDATE операцій на одній колонці
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 при записі
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 перевіряє коректність імен таблиць, колонок і умов з'єднання під час компіляції
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;
}Для запобігання розбіжностям між схемою PostgreSQL та типами TypeScript у репозиторії впроваджується автоматична перевірка в 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Використання типу 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 опціональні, а обов'язкові поля строго вимагаються
return await db.insert(usersTable).values(data).returning();
}Утиліта createInsertSchema(table) автоматично перетворює всі колонки таблиці на вхідний валідатор. Прямий експорт цієї схеми на публічний маршрут дозволяє зловмиснику надіслати поля id, createdAt, isActive: true або role: 'superadmin', перезаписавши критичні системні атрибути:
// ❌ ВРАЗЛИВО: Пряме використання сирої схеми таблиці
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-колонка має тип unknown
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 для Node.js pg повертає колонки numeric і decimal у вигляді рядків (string), щоб запобігти втраті точності при роботі з великими числами. Оголошення balance: number у Kysely пройде перевірку компілятора, але під час виконання операція balance + 100 перетвориться на строкову конкатенацію ("1500.50100"):
// ❌ НЕБЕЗПЕЧНО: Оголошення numeric як number
export interface AccountsTableBad {
id: Generated<number>;
balance: number; // ПОМИЛКА РАНТАЙМУ: драйвер повертає рядок "1500.50"!
}
// ✅ ПРАВИЛЬНО: Чесне зазначення типів через ColumnType
export interface AccountsTableCorrect {
id: Generated<number>;
// Читається як рядок; для обчислень використовується Decimal.js або явне приведення
balance: ColumnType<string, string | number, string | number>;
}Опишіть таблиці, foreign keys, індекси та обмеження на TypeScript. Використовуйте .$type<T>() для JSONB-полів.
Запустіть Drizzle Kit CLI для створення детермінованих SQL-файлів міграцій на основі порівняння схем.
Експортуйте Select та Insert моделі через $inferSelect і $inferInsert для використання в сервісах.
Згенеруйте схеми валідації вхідних даних для API з обов'язковим викликом .omit() для службових колонок.
Налаштуйте виконання міграцій та перевірку відсутності git diff у CI для 100% захисту від Type Drift перед релізом.
Паттерн Type Providers у сучасних TypeScript-застосунках перетворює схему бази даних на Єдине джерело правди (Single Source of Truth). Застосування Drizzle ORM та Kysely дозволяє:
Select) та запису (Insert) даних;drizzle-zod) без дублювання декларацій полів;