Проверка типов против транспиляции: почему твой бандлер не ловит ошибки типов
esbuild/swc/babel транспилируют пофайлово и НЕ проверяют типы — ошибка типа не остановит бандл. Эта модель требует `isolatedModules` (запрет const enum между файлами, нужен `export type`). Паттерн: бандлер собирает, а `tsc --noEmit` — отдельный гейт типов в CI.
Коллега выкатил код, где const total: number = someUserId; — присваивание строки в number. Vite собрал без единого писка, приложение задеплоилось, и баг вылез только в production, когда арифметика вернула NaN. «Но это же TypeScript!» — да, но Vite использует esbuild, который транспилирует TypeScript и никогда не проверяет типы. Аннотация типа была удалена, а не проверена. Ничто в конвейере сборки не запускало проверщик типов.
Две совершенно разные задачи: проверка против транспиляции
tsc делает две вещи: проверяет типы (анализирует всю программу на ошибки типов) и генерирует JavaScript. Бандлеры разделяют их и оставляют только вторую:
| инструмент | проверяет типы? | emit | скорость | видит другие файлы? |
|---|---|---|---|---|
tsc | да (вся программа) | JS + .d.ts | медленно | да — межфайловый анализ |
esbuild | нет | JS | очень быстро | нет — по одному файлу |
swc | нет | JS | очень быстро | нет — по одному файлу |
babel (preset-typescript) | нет | JS | быстро | нет — по одному файлу |
tsc --noEmit | да | ничего | медленно | да — CI-гейт типов |
Причина, по которой бандлеры в 10–100× быстрее tsc, именно в том, что они не проверяют типы. Анализ типов требует построения графа всей программы; транспиляция просто удаляет синтаксис типов токен за токеном в одном файле. Ошибка типа им невидима — это просто синтаксис, который они стирают.
isolatedModules: делаем однофайловую транспиляцию безопасной
Если твоя команда использует esbuild или swc для emit, есть целый класс валидного TypeScript, который молча сгенерирует неправильный JavaScript — не ошибку, а просто сломанный бандл. isolatedModules — флаг, который ловит такие конструкции в редакторе, а не в рантайме.
Поскольку esbuild/swc/babel компилируют по одному файлу за раз, не зная о других файлах, некоторые конструкции TypeScript небезопасно так транспилировать — им нужна информация о всей программе. isolatedModules: true заставляет tsc помечать именно такие конструкции, чтобы твой код был портируем на однофайловый транспилятор:
// с isolatedModules эти конструкции дают ошибку в tsc:
// 1. type-only реэкспорт, ВЫГЛЯДЯЩИЙ как реэкспорт значения:
export { SomeType } from "./types";
// ^^ ~TS1205: однофайловый компилятор не может понять, SomeType — это
// тип (стереть экспорт) или значение (оставить). Фикс:
export type { SomeType } from "./types"; // теперь однозначно — стереть
// 2. const enum между файлами — нужны значения enum, вшитые из другого файла:
import { Color } from "./enums";
const c = Color.Red; // const enum нельзя вшить без межфайловой информацииisolatedModules не меняет emit; он ограничивает то, что можно писать, чтобы пофайловая транспиляция давала корректный вывод. Две главные вещи, которые он фактически запрещает: const enum, потребляемый между файлами (однофайловый компилятор не может вшить значения, которых не видит) и неоднозначные type-only реэкспорты (вынуждая export type / import type).
▸Почему это работает
Почему однофайловый транспилятор не справляется с export { SomeType } from "./types"? Потому что для генерации корректного JS он должен решить, SomeType — это тип (стереть реэкспорт целиком — типы не существуют в рантайме) или значение (оставить реэкспорт). Это решение требует открыть ./types и проверить, что такое SomeType — межфайловое знание, которое транспилятор ради скорости собирать отказывается. export type { SomeType } говорит ему однозначно «это тип, сотри», поэтому isolatedModules (и verbatimModuleSyntax) подталкивают к явным модификаторам type.
Паттерн: бандлер собирает, tsc --noEmit гейтит
Production-настройка развязывает две задачи. Бандлер быстро производит артефакт; tsc --noEmit (часто tsc -b --noEmit для монорепо) запускается параллельно или в CI чисто чтобы проверить типы — он ничего не генерирует, лишь валит сборку, если тип неверен:
// скрипты package.json
{
"scripts": {
"build": "vite build", // esbuild транспилирует — БЕЗ проверки типов
"typecheck": "tsc --noEmit", // настоящий гейт типов — ничего не генерирует
"ci": "npm run typecheck && npm run build"
}
}verbatimModuleSyntax (TS 5.0) усиливает это: он делает emit import/export буквальным, так что обычный import всегда сохраняется, а стираются только import type/export type. Эта предсказуемость и нужна транспилятору — никакого угадывания, был ли импорт «использован как значение». Он заменяет старый importsNotUsedAsValues и закрывает те же однофайловые заботы, которые помечает isolatedModules. (Заметь: сам tsc всё ещё делает declaration emit — генерацию .d.ts — чего транспилятор не делает никогда, поэтому сборки библиотек оставляют tsc для типов, даже когда JS производит esbuild.)
Транспилятор вроде esbuild относится к проверке типов так же, как быстрый копировальный аппарат относится к ___ — он быстро воспроизводит страницы, но никогда не читает их на ошибки.
Твоё Vite-приложение собирается и деплоится, хотя в файле явная ошибка типа (присваивание строки в number). Почему сборка не упала и как это поймать?
- 01Почему ошибка типа проходит сквозь сборку esbuild/swc/babel и каков фикс?
- 02Что такое isolatedModules, почему он нужен однофайловым транспиляторам и что он запрещает?
- 03Что за паттерн бандлер-плюс-tsc-noEmit и где тут verbatimModuleSyntax / declaration emit?
Теперь ты знаешь разделение труда, определяющее современную сборку TypeScript: транспиляторы генерируют JS пофайлово и никогда не проверяют типы, isolatedModules держит код безопасным для этой модели (нет межфайловых const enum, явный export type), а tsc --noEmit — гейт, реально обеспечивающий безопасность типов, пока бандлер отвечает за скорость. Это закрывает юнит config/modules/build. Дальше, в TypeScript на практике, мы применяем всё это — частые ловушки, где эти тонкости конфига и emit бьют команды в production-коде. Теперь, когда видишь ошибку типа, проскользнувшую в production через Vite-сборку, знаешь, чего не хватало: tsc --noEmit никогда не был в CI-пайплайне.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.