Схема на TypeScript: как Drizzle выводит типы из таблиц

база≈ 17 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Своя таблица из начала главы хранит строки, чинит индексы и отказывает дублям, а репозиторий решает, кому какие строки видны. Не хватает одного — сказать форму заметки один раз. Сейчас она написана дважды: типом в коде и колонками в базе, и расходятся эти два описания молча. Drizzle предлагает описать таблицу значением на TypeScript и вывести из него всё остальное: тип строки, тип вставки и сам CREATE TABLE. Приём знаком — ровно так схема валидатора выдавала тип разобранного тела. Вся разница в двух словах, notNull и hasDefault: из них берётся и объединение с null, и необязательный ключ при вставке. Сначала вы напишете этот вывод сами, потом посмотрите на настоящие $inferSelect и $inferInsert глазами настоящего компилятора — и найдёте три места, где описание обещает не то, что стоит в базе.

Разделы темы:·

Описание таблицы у Drizzle — не файл на особом языке и не строка SQL, а обычное значение. Функция pgTable принимает объект колонок и возвращает объект; колонки тоже объекты, и в каждом лежит ровно то, что о колонке нужно знать: какой у неё тип в базе, обязательна ли она (notNull), есть ли у неё значение по умолчанию (hasDefault) и — отдельно, для компилятора — какие данные в ней лежат.

Мелочь, на которой держится всё остальное: флаги в типе колонки — не boolean, а true или false по отдельности. Проверка notNull extends true спрашивает, годится ли левый тип на место правого: true годится, false нет, а boolean — это оба сразу, и определённо он не годится тоже. Вся разметка колонок ушла бы в одну ветку, и обязательная перестала бы отличаться от необязательной, хотя в значении флаг записан верно. Поэтому у каждой обёртки свой тип возврата, где стоит именно литерал.

А вот тип данных колонки хранить негде: в значении лежат строки и флаги, а string или Date — это тип, и до выполнения он не доживает. В миниатюре ниже ему отведено настоящее поле sample с образцом значения. У Drizzle такое поле называется _, и значения у него не бывает вовсе — оно объявлено только в типе. Чем это оборачивается, увидим в прогоне.

Три вида колонок и две обёртки — всё описание. Больше ни одной строки про title и status в типах писать не придётся: их скажет само описание.

Первым это описание читает генератор CREATE TABLE: он проходит по ключам объекта и складывает строку из тех же полей, которые лежат в колонке. Прогноз: что он напечатает про body — и в каком порядке встанут DEFAULT и NOT NULL?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

У body нет ни одного слова после типа: в SQL отсутствие NOT NULL и есть разрешение на NULL. Отдельного слова NULL писать не нужно, и именно поэтому забытый notNull никак себя не проявляет — колонка просто молча разрешает пустоту. Порядок слов тоже не случайность: DEFAULT стоит перед NOT NULL, так его печатает и настоящий drizzle-kit, и дальше в статье это пригодится для сверки.

Пять колонок дают три сочетания двух флагов из четырёх возможных: обязательная без умолчания (title, author), необязательная без умолчания (body) и обязательная с умолчанием (status, createdAt). Четвёртого — колонки без notNull, но с умолчанием — в этой таблице нет, и это к лучшему: тип вставки посчитал бы её необязательной сразу по двум причинам, а различить их было бы нечем.

Конфигурация колонки нужна в типе целиком: и флаги, и тип данных. Флаги честно лежат в значении, и типу достаточно на них сослаться — notNull: C["notNull"]. С данными так не выйдет: string — не значение, положить его в объект нельзя. Остаются два пути. Первый — держать рядом образец нужного типа, как здесь: поле sample настоящее, в нём лежит пустая строка, ноль или new Date(0), и компилятору этого хватает. Второй — объявить поле и никогда его не присваивать: тип у поля есть, значения нет, доступ к нему в рантайме даст undefined. Drizzle идёт вторым путём, и поле у него называется _.

Второй читатель того же описания — компилятор. Ему нужен тип строки, какой её отдаёт чтение таблицы: те же ключи, а под каждым — тип данных колонки. Проход по ключам знаком по сопоставленным типам, выбор ветки — по условным, а typeof notes берёт тип с уже написанного значения, как в разборе typeof в позиции типа. Ничего нового не понадобится: весь вывод — две строки.

Прогноз: сколько полей окажется в строке и что будет в body? И три строки с @ts-expect-error — на каждой компилятор обязан ругаться, иначе директива сама станет ошибкой.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Колонок пять — полей в строке тоже пять. Это первое, что стоит запомнить: body объявлена без notNull, но необязательной в строке она от этого не стала. Чтение таблицы возвращает все колонки, и в отсутствующем значении приезжает не пропущенный ключ, а null. Поэтому тип и получается string | null, а не string | undefined и не ключ со знаком вопроса.

Дальше начинается то, ради чего это писалось. Присвоить null в title нельзя — у колонки стоит notNull, и ветка условного типа выбрала голый string. Пропустить body тоже нельзя — ключ обязателен, даже когда значения нет. А обращение note.body.length компилятор не пропускает вовсе: объединение с null разбирают до обращения, а не после. Разбирают его теми же приёмами, что и всякое другое, — и с той же оговоркой, что в сужении по истинности: if (row.body) отсечёт заодно и пустую строку, а в колонке она законное значение. Новое здесь только то, что объединение приезжает не от вас, а из описания колонки.

Соблазн понятный: раз значения может не быть, пусть и ключа не будет. Но тогда каждое обращение к строке пришлось бы начинать с проверки "body" in row, а ошибка «забыл колонку в запросе» и ошибка «в колонке пусто» стали бы неразличимы. Объединение с null разделяет их: ключ есть всегда, и значит, запрос колонку вернул; значение null значит, что в ней пусто. Необязательный ключ появится в другом типе — в типе вставки, и по другой причине.

Тип вставки отвечает на другой вопрос: что обязан передать я, а что заполнится само. Обязательной колонку делает сочетание двух флагов — notNull стоит, а умолчания нет: заполнить её больше некому. Всё остальное необязательно, причём по разным причинам: у status и createdAt есть умолчание, у body его нет, но есть разрешение на NULL.

Поделить ключи на две группы одним проходом нельзя: знак вопроса приписывается всему отображению разом, отдельным ключам его не поставить. Зато ключ можно выбросить — as-клаузула переименовывает ключ, а если посчитать его в never, свойства в результате не окажется вовсе. Отсюда приём: два прохода по одним и тем же колонкам, в первом в never уходят необязательные, во втором обязательные, и результаты пересекаются.

Прогноз: какие колонки попадут в список обязательных и что скажет компилятор на четыре объекта под директивами?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Обязательны title и author — ровно те две колонки, у которых notNull без умолчания. Остальные три необязательны, и объект из двух полей проходит. Заметьте, чего в этом типе нет: ни одного имени колонки, написанного руками. Опечатка stats ловится не списком разрешённых имён, а тем, что такого ключа не дало ни одно из двух отображений.

Последняя строка типа — Simplify. Пересечение двух отображений подсказка редактора показывает как есть, двумя объектами через &, и читать это неудобно; проход, который ничего не меняет, заставляет компилятор свести пересечение к одному объекту. Приём общий, но в этом месте он не украшение: у Drizzle тип вставки собран точно так же и обёрнут точно тем же Simplify.

Условие обязательности написано дважды — в RequiredKey и, отрицанием, в OptionalKey. Разойдясь, эти два прохода дают не ошибку компиляции, а тихую беду: колонка, попавшая в обе группы, получит и обязательный, и необязательный ключ, а пересечение сведёт их к обязательному; колонка, не попавшая никуда, из типа вставки исчезнет, и передать её станет нельзя. У Drizzle второе условие поэтому написано через первое: TKey extends RequiredKeyOnly<TKey, T> ? never : … — одна проверка, а не две согласованные.

Дальше миниатюру можно закрыть: настоящая схема пишется теми же словами. Колонки объявляются вызовами text(), integer(), varchar(), timestamp(), обёртки складываются в цепочку — .notNull().default("draft"), — а pgTable собирает из них объект таблицы. Первое, что стоит проверить: те же ли это два флага и где на самом деле лежит конфигурация, которую читает компилятор. И то и другое видно обычным console.log — колонка ведь обычный объект.

прогон node 26.5.0 · 23 сентября 2026
Что на самом деле лежит в объекте колонки настоящего Drizzle и какие поля у него есть
import { integer, pgTable, text, timestamp, varchar } from 'drizzle-orm/pg-core';

/*
 * Та же таблица заметок, что и в остальной главе, только описана не кодом, а
 * значением: pgTable принимает объект колонок и возвращает объект, который
 * потом читают трое — компилятор, drizzle-kit и сам запрос.
 */
export const notes = pgTable('notes', {
  id: integer().primaryKey().generatedAlwaysAsIdentity(),
  title: varchar({ length: 80 }).notNull(),
  author: text().notNull(),
  body: text(),
  status: text({ enum: ['draft', 'published'] }).notNull().default('draft'),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  // значение считает JS на каждой вставке, а не база
  editedAt: timestamp('edited_at', { withTimezone: true }).notNull().$defaultFn(() => new Date()),
});

export type Note = typeof notes.$inferSelect;
export type NewNote = typeof notes.$inferInsert;
node flags.ts

Флаги на месте и хранятся обычными булевыми значениями: notNull и hasDefault — это поля объекта, а не выдумка системы типов. А поля _ в объекте нет — проверка "_" in column отвечает false у каждой колонки. Оно объявлено в классе и никогда не присваивается: конфигурацию из него читает только компилятор. Обратите внимание и на строку id: у неё hasDefault: true, хотя никакого .default() в описании не стоит — значение ей выдаёт база.

Теперь сами типы. typeof notes.$inferSelect и typeof notes.$inferInsert — это ровно то, что вы написали: проход по колонкам, notNull решает про null, notNull без умолчания решает про обязательность. Проверить это можно одним способом — попросить компилятор напечатать, что у него получилось. Он и напечатает: в сообщении о несовпадении стоит тип целиком.

В файле четыре объекта, каждый с одной ошибкой. Прогноз: на что пожалуется компилятор в строке с id — на тип значения или на само имя?

прогон node 26.5.0 · 23 сентября 2026
Настоящие типы строки и вставки под настоящим компилятором: четыре объекта, которые он разбирает по одному
import { integer, pgTable, text, timestamp, varchar } from 'drizzle-orm/pg-core';

/*
 * Та же таблица заметок, что и в остальной главе, только описана не кодом, а
 * значением: pgTable принимает объект колонок и возвращает объект, который
 * потом читают трое — компилятор, drizzle-kit и сам запрос.
 */
export const notes = pgTable('notes', {
  id: integer().primaryKey().generatedAlwaysAsIdentity(),
  title: varchar({ length: 80 }).notNull(),
  author: text().notNull(),
  body: text(),
  status: text({ enum: ['draft', 'published'] }).notNull().default('draft'),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  // значение считает JS на каждой вставке, а не база
  editedAt: timestamp('edited_at', { withTimezone: true }).notNull().$defaultFn(() => new Date()),
});

export type Note = typeof notes.$inferSelect;
export type NewNote = typeof notes.$inferInsert;
tsc --noEmit

Четыре сообщения — четыре подтверждения. body обязателен в типе строки и объявлен как string | null: колонка без notNull, всё как в миниатюре. author обязателен при вставке, а body, status, createdAt и editedAt стоят там со знаком вопроса. Строка с "archived" — старая знакомая: text({ enum: […] }) сужает тип данных до объединения литералов ровно так же, как это делал oneOf у своего валидатора.

А вот id отличился. Компилятор жалуется не на тип значения, а на то, что такого свойства в типе вставки нет вовсе: колонка объявлена как generatedAlwaysAsIdentity(), и Drizzle убирает её из вставки целиком. Разница с необязательным ключом принципиальная: необязательный можно передать, а этот передать нельзя — GENERATED ALWAYS в Postgres означает, что номер назначает база, и обычный INSERT со своим значением она отклоняет.

В миниатюре два флага, у Drizzle их больше: кроме notNull и hasDefault колонка помнит ещё generated и identity. Обязательность считается по-прежнему двумя первыми, а вот второй проход, тот самый с необязательными ключами, сначала спрашивает про эти два: колонку-выражение (generated) и колонку с identity: "always" он не пропускает никуда, и ключ исчезает. У generatedByDefaultAsIdentity() флаг стоит в "byDefault", и ключ остаётся необязательным — номер можно передать свой. Добавить такой флаг в миниатюру — первая задача статьи.

Третий читатель описания — drizzle-kit, отдельная программа рядом с библиотекой. Ей от схемы нужны не типы, а те же самые поля объекта: имя, тип SQL, оба флага и текст умолчания. Команда drizzle-kit export печатает, во что превращается схема, и базы ей для этого не нужно — как и вашему генератору из первого раздела. Прогноз: совпадёт ли этот текст с тем, что напечатал ваш генератор, и что станет с именами колонок?

прогон node 26.5.0 · 23 сентября 2026
Что drizzle-kit складывает из описания схемы, не открывая ни одного соединения с базой
import { integer, pgTable, text, timestamp, varchar } from 'drizzle-orm/pg-core';

/*
 * Та же таблица заметок, что и в остальной главе, только описана не кодом, а
 * значением: pgTable принимает объект колонок и возвращает объект, который
 * потом читают трое — компилятор, drizzle-kit и сам запрос.
 */
export const notes = pgTable('notes', {
  id: integer().primaryKey().generatedAlwaysAsIdentity(),
  title: varchar({ length: 80 }).notNull(),
  author: text().notNull(),
  body: text(),
  status: text({ enum: ['draft', 'published'] }).notNull().default('draft'),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  // значение считает JS на каждой вставке, а не база
  editedAt: timestamp('edited_at', { withTimezone: true }).notNull().$defaultFn(() => new Date()),
});

export type Note = typeof notes.$inferSelect;
export type NewNote = typeof notes.$inferInsert;
drizzle-kit export --sql

Сверьте с выводом своего генератора: те же слова и тот же порядок — DEFAULT перед NOT NULL, отсутствие слова вместо разрешения на NULL. Настоящая схема богаче миниатюрной: в ней есть id, у title записана длина, а у обоих времён — часовой пояс; к этим строкам мы ещё вернёмся. Отдельно от типов разошлись имена колонок. У title, author, body и status имя в базе взято из ключа объекта, а created_at и edited_at названы явно: имя в базе — необязательный первый аргумент колонки, и, пока его не написали, Drizzle берёт ключ как есть, ничего не переименовывая.

Этот текст — ещё не миграция. Миграция отличается тем, что её кладут в репозиторий файлом, а применённое отмечают в самой базе; export же просто показывает схему целиком, как если бы базы не существовало. Чем это отличается от generate и что происходит с миграцией, поправленной задним числом, — в следующей статье.

И одна строка, мимо которой легко пройти: у edited_at стоит NOT NULL, а DEFAULT — нет, хотя в типе вставки этот ключ необязателен и прошлый прогон это показал. С этого расхождения и начинается последний раздел.

Схема одна, а выводов из неё два, и они не совпадают. Положим их рядом: слева то, что обещает тип, справа — то, что написано в CREATE TABLE. Оба столбца сняты прогонами выше, ничего не придумано.

КолонкаЧто обещает типЧто стоит в базе
titlestring любой длиныvarchar(80)
status"draft" | "published"text без единой проверки
editedAtнеобязательный ключ при вставкеNOT NULL без DEFAULT

Расхождения не однородны: в двух случаях тип обещает больше, чем примет база, а в одном — меньше, чем она пропустит. Длину varchar тип не переносит вовсе: title в нём просто string. Объединение "draft" | "published", наоборот, живёт только в типе — в SQL стоит обычный text, и ни CHECK, ни перечисления там нет. А editedAt объявлен как .notNull().$defaultFn(…): функция считает значение в JavaScript на каждой вставке, поэтому hasDefault стоит и ключ необязателен, но в базу никакого DEFAULT при этом не попадает.

Проверим все три разом на настоящей базе, созданной тем самым CREATE TABLE — файл schema.sql в прогоне ниже скопирован из вывода export дословно. Четыре записи: объект той формы, которую компилятор назвал достаточной; он же, написанный обычным INSERT, как пишет соседний сервис или миграция; строка со статусом archived; и заголовок в сто знаков через Drizzle. Прогноз: сколько из четырёх пройдёт и какие коды приедут у остальных?

прогон под базой PostgreSQL 18.4 · node 26.5.0 · 23 сентября 2026
Четыре записи в настоящую базу, созданную тем самым CREATE TABLE, и чтение того, что в ней осталось
import { integer, pgTable, text, timestamp, varchar } from 'drizzle-orm/pg-core';

/*
 * Та же таблица заметок, что и в остальной главе, только описана не кодом, а
 * значением: pgTable принимает объект колонок и возвращает объект, который
 * потом читают трое — компилятор, drizzle-kit и сам запрос.
 */
export const notes = pgTable('notes', {
  id: integer().primaryKey().generatedAlwaysAsIdentity(),
  title: varchar({ length: 80 }).notNull(),
  author: text().notNull(),
  body: text(),
  status: text({ enum: ['draft', 'published'] }).notNull().default('draft'),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  // значение считает JS на каждой вставке, а не база
  editedAt: timestamp('edited_at', { withTimezone: true }).notNull().$defaultFn(() => new Date()),
});

export type Note = typeof notes.$inferSelect;
export type NewNote = typeof notes.$inferInsert;
node probe.ts

Прошли две из четырёх. Через Drizzle объект из двух полей вставился — editedAt заполнила та самая функция, и в строке стоит настоящая дата. Тот же объект обычным INSERT отказал кодом 23502 с именем колонки: для базы edited_at обязателен, умолчания у него нет, а про функцию в JavaScript база ничего не знает. Заголовок в сто знаков отказал кодом 22001 — тем самым varchar(80), которого в типе не было. Приехали эти два отказа по-разному: свой INSERT отдал ошибку драйвера как есть, а вставку через Drizzle библиотека завернула в свою, и код нашёлся в cause. Оба уровня и смотрела наша проверка отказа из базы.

Самое неприятное — третья строка. Обычный INSERT со статусом archived прошёл без единого возражения, и чтение через Drizzle вернуло эту строку как ни в чём не бывало: в поле status лежит "archived", хотя тип клянётся, что там бывает только "draft" или "published". Никакой switch по статусу с проверкой на забытую ветку такую строку не поймает. Заодно в таблице остались только номера 1 и 3: неудачная вставка номер сжигает — это мы уже видели на откате.

Вывод простой и неприятный: $inferSelect — это обещание про описание схемы, а не про содержимое базы. Оно сбывается ровно настолько, насколько база соответствует схеме и насколько единственная дверь к строкам — ваш код. Лекарство знакомо по границе программы: то, что пришло снаружи, разбирают, а не объявляют. А то, что можно заставить проверять саму базу — длину, перечисление, CHECK, — лучше туда и положить: настоящим ограничениям в главе отведена своя статья.

  • Схема таблицы у Drizzle — обычное значение: объект колонок, в каждой из которых лежат тип SQL, флаг notNull, флаг hasDefault и текст умолчания. Читателей у этого значения трое: компилятор берёт из него типы, drizzle-kit — CREATE TABLE, запрос — имена колонок.
  • Тип строки — проход по колонкам, в котором notNull выбирает ветку условного типа: с флагом данные как есть, без него — объединение с null. Колонка при этом остаётся обязательным ключом: чтение возвращает все колонки, и пустота приезжает значением, а не пропуском.
  • Тип вставки собирается двумя проходами по тем же колонкам: обязательные — те, у кого notNull без умолчания, остальные получают знак вопроса. Делит их as-клаузула: ключ, посчитанный в never, из объекта пропадает.
  • Флаги лежат в значении обычными булевыми полями, а конфигурация для компилятора — в поле _, которого в объекте не бывает: оно объявлено и никогда не присваивается. Колонка с GENERATED ALWAYS из типа вставки исчезает совсем — передать её значение нельзя, а не просто необязательно.
  • Описание одно, а выводов из него два, и совпадают они не во всём: длина varchar в тип не переносится, объединение литералов не превращается в ограничение базы, а умолчание, считаемое в JavaScript, не даёт колонке DEFAULT в SQL.
  • $inferSelect — обещание про схему, а не про содержимое базы. Строка, записанная мимо библиотеки, приезжает с тем значением, какое в ней лежит; то, что должно быть правилом для всех, кладут ограничением в саму базу, а пришедшее снаружи разбирают на границе.

1/4Почему в типе колонки флаг записан как true или false, а не как boolean?

2/4Колонка body объявлена без notNull и без умолчания. Чем она будет в типе строки и в типе вставки?

3/4Колонка id объявлена как generatedAlwaysAsIdentity(). Что с ней в типе вставки?

4/4У колонки стоит .notNull().$defaultFn(() => new Date()). Что напечатает про неё drizzle-kit export?

Колонка, значение которой считает JavaScript

В миниатюру добавили withJsDefault — умолчание, которое считается функцией на каждой вставке, а не базой. Ключ при вставке он делает необязательным правильно, а вот в CREATE TABLE уезжает DEFAULT, которого в базе быть не должно: база про эту функцию ничего не знает. Почините withJsDefault, не трогая ни Insert, ни createTable и не заводя третьего флага.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Умолчание в JavaScript и умолчание в SQL — одно и то же для типа вставки и разные вещи для генератора. Значит, флаг hasDefault ставится в обоих случаях, а defaultSql остаётся пустым, когда значение считает функция.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

После правки editedAt печатается как "editedAt" timestamp NOT NULL — ровно так же, как edited_at у настоящего drizzle-kit, а у createdAt DEFAULT остаётся: там умолчание и вправду в базе. Сама функция в миниатюре никуда не сохраняется — вставок у неё нет; настоящий Drizzle держит её в колонке и зовёт на каждой вставке, и именно поэтому запись мимо библиотеки получает отказ 23502.

Вставка потеряла колонку

Функция insertRow собирает строку из переданных значений и умолчаний — так же, как это делала бы база. Тип Row обещает, что в строке пять полей, а приведение as Note это обещание подтверждает, никем не проверенное. Сделайте так, чтобы строка и вправду совпадала с типом; проверку обязательных колонок не трогайте.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Колонка без notNull и без умолчания тоже часть строки: база положит в неё NULL, и об этом говорит сам тип — string | null, а не string | undefined. Значит, в сборке нужна третья ветка.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Полей становится пять, и body приезжает как null. Заодно виден урок про приведение: as Note не проверяет ничего — компилятор просто поверил на слово, и разошлись бы тип и значение молча. Настоящая строка приходит из драйвера, и там такой сборкой занимается сама база.