12k
All articles

Лучшие практики TypeScript для крупных проектов

Практики TypeScript для крупных проектов: strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes, генерация типов, runtime-валидация и CI-контроль.

OpenReplay Team
OpenReplay Team
Лучшие практики TypeScript для крупных проектов

В масштабе TypeScript окупается только при последовательной дисциплине: strict mode как базовый уровень, явные и валидированные границы, типы, генерируемые на стыках систем, чтобы команды не расходились, и небольшой набор паттернов, с которыми согласна вся команда. Язык перестал быть главной проблемой много лет назад — главная проблема сегодня состоит в том, чтобы кодовая база на миллион строк с множеством контрибьюторов оставалась рефакторируемой без регрессий по типобезопасности, которые норовят пробраться в каждый PR. Это руководство охватывает соглашения, флаги компилятора и архитектурные паттерны, которые выдерживают такой масштаб, включая путь миграции для расслабленной кодовой базы, которую вы, вероятно, унаследовали. Материал актуален для двух релизов, переопределивших ландшафт 2026 года: TypeScript 6.0 (GA) и 7.0 (Release Candidate).

Ключевые выводы

  • Начиная с TypeScript 6.0 (выпущен 23 марта 2026 года), strict по умолчанию имеет значение true на уровне компилятора, поэтому в современной крупной кодовой базе strict mode — это отправная точка, а не финишная черта.
  • Два флага, которые реально влияют на крупную кодовую базу, вообще не входят в strict: noUncheckedIndexedAccess и exactOptionalPropertyTypes необходимо включать явно — они отлавливают ошибки при обращении к элементам массива по индексу и при работе с опциональными свойствами, которые strict молча пропускает.
  • Генерируемые типы — это единственная практика с наибольшей отдачей для многокомандной кодовой базы: когда фронтенд и бэкенд оба выводят типы из одной схемы OpenAPI или Prisma, две стороны физически не могут разойтись, а CI падает в момент изменения контракта.
  • Статические типы — это обещание времени компиляции, а не проверка во время выполнения: ответ, типизированный как User, лишь утверждается таковым, именно поэтому каждая внешняя граница нуждается в валидации во время выполнения в дополнение к сгенерированному типу.
  • По данным Microsoft, компилятор TypeScript 7.0 на основе Go нередко работает примерно в 10 раз быстрее, чем 6.0, на крупных кодовых базах; в Release Candidate от июня 2026 года он поставляется внутри стандартного бинарника tsc и пакета typescript.

Дисциплина компилятора: strict mode — это пол, а не достижение

Каждая поверхностная статья о лучших практиках до сих пор советует «включить strict mode», как будто это героический opt-in. Такая подача устарела. Начиная с заметок о выпуске TypeScript 6.0, strict по умолчанию равен true на уровне компилятора — если вы рассчитывали на старое значение по умолчанию false, теперь вам придётся явно указывать "strict": false. TypeScript 6.0 был анонсирован 23 марта 2026 года и задуман как последний релиз на текущей кодовой базе JavaScript. Таким образом, в любом проекте, обновившем компилятор, strict является предполагаемым базовым уровнем.

Реальное улучшение для крупного проекта — это два высокоценных флага, которые strict не включает. strict активирует примерно девять проверок типобезопасности (noImplicitAny, strictNullChecks и другие), но не включает noUncheckedIndexedAccess, который добавляет undefined к каждому необъявленному обращению по индексу, и exactOptionalPropertyTypes, который различает свойство, установленное в undefined, и отсутствующее свойство. Они отлавливают именно те ошибки, которые проскальзывают сквозь «строгую» кодовую базу: обращение к элементу массива с предположением о его существовании и опциональное поле, которое присутствует, но равно undefined.

Версионированный tsconfig.json для крупного проекта (TypeScript 6.0.x):

{
  "compilerOptions": {
    "strict": true,                      // по умолчанию с 6.0; оставьте явным для старых тулчейнов
    "noUncheckedIndexedAccess": true,    // arr[i] имеет тип T | undefined, а не T
    "exactOptionalPropertyTypes": true,  // { x?: number } отклоняет { x: undefined }
    "verbatimModuleSyntax": true,        // принудительные type-only импорты (см. производительность сборки)
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "composite": true                    // обязателен для project references
  }
}

Пошаговый план миграции к строгости

Если вы унаследовали расслабленную кодовую базу, не включайте все флаги сразу — применяйте строгость директория за директорией, отслеживайте процент type-coverage в CI и поднимайте порог так, чтобы покрытие никогда не могло регрессировать. Приложение на 200 тысяч строк с тысячами неявных any не скомпилируется чисто с первого дня, а PR с 2800 ошибками невозможно проревьюить.

Реалистичная последовательность:

  1. Включите strict: true глобально, но ограничьте область применения: сохраните разрешительный базовый tsconfig и добавьте более строгие файлы tsconfig.json для каждой директории фич с использованием project references, сначала устраняя наиболее проблемные места.
  2. Добавьте type-coverage в CI как храповик — роняйте сборку, если процент типизированных символов опускается ниже последнего зафиксированного значения. Покрытие может выйти на плато, но не может регрессировать.
  3. Поэтапно вводите дополнительные флаги (noUncheckedIndexedAccess, exactOptionalPropertyTypes), расставляя // @ts-expect-error на оставшихся нарушениях, а затем сокращайте этот список. @ts-expect-error сам сообщает, когда подавление становится ненужным, поэтому бэклог не может незаметно гнить.

Проектирование типов в масштабе

Хорошие типы в масштабе делают недопустимые состояния некомпилируемыми и громко сигнализируют о доменных ошибках. Три паттерна выполняют большую часть работы; остальное — вопрос последовательности.

Правило interface vs type, сформулированное один раз: используйте interface для публичных, расширяемых объектных контрактов (он поддерживает declaration merging и, как правило, даёт более понятные сообщения об ошибках для больших объектных форм), а type — для объединений, пересечений, mapped и conditional типов. Вот и весь спор. Выберите правило, закрепите его в линтере и двигайтесь дальше.

Делайте недопустимые состояния непредставимыми

«Суп» из булевых флагов — наиболее распространённый источник ошибок типа «такого никогда не должно происходить» в крупных UI-кодовых базах. Тип ниже допускает шестнадцать комбинаций, большинство из которых бессмысленны — isLoading и error установлены одновременно, data присутствует во время ошибки:

// Антипаттерн: каждое поле независимо, недопустимые состояния разрешены
interface RequestState<T> {
  isLoading: boolean;
  isError: boolean;
  data?: T;
  error?: Error;
}

Discriminated union сводит это ровно к тем состояниям, которые могут возникнуть, и компилятор вынуждает вас обработать каждое из них:

type RequestState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function render<T>(state: RequestState<T>) {
  switch (state.status) {
    case "success":
      return state.data;   // data существует только здесь
    case "error":
      return state.error;  // error существует только здесь
    // пропущенный case является ошибкой компиляции при правильной проверке исчерпаемости
  }
}

Записи сессий для состояния запроса на булевых флагах нередко демонстрируют именно тот сценарий отказа, который устраняет этот паттерн: UI, одновременно отображающий спиннер и устаревшие данные, потому что два независимых булевых значения вышли из синхронизации.

Брендируйте доменные идентификаторы

Branded types превращают UserId и OrderId в несовместимые типы, даже если оба являются string во время выполнения, делая передачу одного вместо другого ошибкой компиляции:

declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;

const asUserId = (s: string) => s as UserId;

function cancelOrder(id: OrderId) { /* ... */ }

const u = asUserId("u_123");
cancelOrder(u); // ❌ Argument of type 'UserId' is not assignable to 'OrderId'

В сигнатуре функции с пятью строковыми аргументами брендирование — это разница между ошибкой перестановки аргументов, обнаруженной во время компиляции, и той, что обнаруживается в продакшене.

Предпочитайте unknown вместо any. any отключает проверку типов и распространяется незаметно; unknown требует шага сужения типа перед использованием. Запретите any в линтере и считайте каждое внешнее значение — результат JSON.parse, привязки в блоке catch, возвраты нетипизированных библиотек — как unknown до тех пор, пока не будет доказано обратное. Используйте as const для литеральных конфигов и таблиц поиска, чтобы они выводились как узкие литеральные типы, а не расширенные примитивы.

Моделируйте и генерируйте типы на границах

Наиболее значимое архитектурное решение в крупной кодовой базе — это то, как вы типизируете края системы. Два правила.

Во-первых, не переиспользуйте один тип User на уровне сети, базы данных и UI — моделируйте ответ API, DTO и доменную сущность как три отдельных типа, чтобы изменение на одной границе не могло незаметно повредить другую. Форма, которую сериализует ваш бэкенд, форма, которую возвращает ORM, и форма, которую потребляют компоненты, со временем расходятся; сворачивание их в один тип связывает каждый слой с каждым другим.

Во-вторых, генерируйте граничные типы вместо того, чтобы писать их вручную. Когда фронтенд и бэкенд оба выводят типы из одной схемы, две стороны физически не могут разойтись, а CI падает в момент изменения контракта. Используйте openapi-typescript для преобразования документа OpenAPI 3.0/3.1 в типы без рантайм-зависимостей, Prisma для типов, производных от базы данных, или GraphQL Code Generator для типизированных операций. Перегенерируйте в CI и падайте при обнаружении расхождения:

# Шаг CI: перегенерировать и упасть, если закоммиченные типы устарели
npx openapi-typescript ./openapi.yaml -o ./src/api/schema.gen.ts
git diff --exit-code ./src/api/schema.gen.ts

Но сгенерированный тип — это всё ещё лишь обещание времени компиляции. Статические типы — это обещание времени компиляции, а не проверка во время выполнения: ответ, типизированный как User, лишь утверждается таковым, именно поэтому каждая внешняя граница нуждается в валидации во время выполнения в дополнение к сгенерированному типу. Валидируйте реальную полезную нагрузку с помощью библиотеки схем, такой как Zod или Valibot, и выводите статический тип из схемы, чтобы одно определение защищало оба слоя:

import { z } from "zod";

const User = z.object({ id: z.string(), email: z.email() });
type User = z.infer<typeof User>;

const res = await fetch("/api/me");
const user = User.parse(await res.json()); // выбрасывает исключение, если реальная форма не совпадает

Аргумент в пользу валидации границ, а не только их типизации, носит эмпирический характер: статические типы исчезают во время выполнения, и запись сессий — один из способов увидеть сбои, которые типы не могут предотвратить: момент, когда реальный ответ API не соответствует объявленной форме и UI ломается в сессии пользователя.

Организация типов для многокомандной кодовой базы

  • Размещайте типы рядом с кодом, который их использует — в том же файле или в соседнем *.types.ts — и резервируйте types/index.ts (или выделенный пакет) только для действительно общих контрактов.
  • Организуйте структуру по папкам фич/доменов, а не по техническим слоям, чтобы типы, компоненты и логика фичи находились вместе и принадлежность была очевидна.
  • В монорепозитории связывайте пакеты с помощью project references и маппинга paths, чтобы импорты пересекали чистые границы модулей (@org/billing) вместо хрупких цепочек ../../../, и чтобы компилятор принудительно соблюдал граф зависимостей.

Производительность сборки в 2026 году: нативный компилятор меняет расчёты

Разговор о производительности сборки фундаментально изменился, и речь больше не идёт об экономии секунд с помощью флагов tsc. TypeScript анонсировал Release Candidate 7.0 18 июня 2026 года; благодаря скорости нативного кода и параллелизму с общей памятью он нередко работает примерно в 10 раз быстрее, чем TypeScript 6.0. Показательный бенчмарк Microsoft проверял кодовую базу VS Code (~1,5 млн строк) примерно за 7,5 секунды против 77,8 на предыдущем компиляторе, хотя на небольших проектах разница меньше.

Ключевое — правильно разобраться с упаковкой. Главное практическое изменение в RC касается именно её: основанная на Go переработка переехала из отдельного пакета native-preview в обычный npm-пакет TypeScript, поэтому TypeScript 7.0 теперь готов к более широкому тестированию в качестве стандартного компилятора tsc. Установите его командой npm install -D typescript@rc и запускайте стандартный бинарник tsc — старые пакеты tsgo / @typescript/native-preview теперь содержат только ночные сборки. По состоянию на конец июня 2026 года последняя стабильная версия — TypeScript 6.0.3, 7.0 находится в стадии RC, а стабильный GA ожидается примерно через месяц после RC. Считайте версию и стадию нестабильными данными и проверяйте их актуальность перед переходом.

Структурные рычаги, которые вы контролируете в собственном конфиге, по-прежнему оправданы на любом компиляторе: project references для инкрементальных, учитывающих зависимости сборок, и type-only импорты, принудительно задаваемые verbatimModuleSyntax, чтобы символы, используемые только в типах, стирались и никогда не эмитировались как рантайм-импорты. verbatimModuleSyntax (введён в 5.0) — это текущий рекомендуемый подход; заменённые им флаги importsNotUsedAsValues и preserveValueImports стали no-op в 5.5 и вызывают ошибку при указании начиная с 6.0.

import type { User } from "./user";  // полностью стирается из JS-вывода
import { fetchUser } from "./api";   // импорт значения, сохраняется

Автоматизируйте защитные ограждения

Соглашения, которые не применяются принудительно, деградируют. Запускайте typescript-eslint с type-aware правилами (no-explicit-any, no-floating-promises, no-misused-promises), чтобы описанные выше паттерны проверялись механически, а не на ревью. Оставьте принудительное применение type-only импортов на verbatimModuleSyntax, а не на lint-правило consistent-type-imports — запускать оба избыточно и может приводить к конфликтующим ошибкам. Запускайте tsc --noEmit в CI на каждом PR как жёсткий шлюз, наряду с храповиком type-coverage из плана миграции. И сохраняйте одно человеческое ограждение поверх всей автоматизации: ясность важнее изощрённости. Глубоко вложенный conditional-and-mapped тип, на чтение которого у старшего инженера уходит десять минут, — это обязательство, а не повод для гордости. Большая часть кода типов в крупном проекте должна быть скучной, читаемой и очевидной.

Сквозная идея — последовательность, а не изощрённость. Включите два флага, которые strict не включает, сделайте недопустимые состояния некомпилируемыми, генерируйте и валидируйте границы, а всё остальное доверьте CI. Возьмите приведённый выше версионированный tsconfig как отправную точку на этой неделе, затем направьте храповик type-coverage на вашу наиболее проблемную директорию и начинайте подниматься.

Часто задаваемые вопросы

Достаточно ли strict mode для крупного TypeScript-проекта?

Нет. Начиная с TypeScript 6.0, strict уже по умолчанию равен true на уровне компилятора, поэтому он является базовым уровнем, а не достижением. Два флага, которые наиболее важны для крупной кодовой базы, вообще не входят в семейство strict: noUncheckedIndexedAccess, который добавляет undefined к необъявленным обращениям по индексу, и exactOptionalPropertyTypes, который различает свойство, установленное в undefined, и отсутствующее свойство. Включите оба явно, затем добавьте граничные типы и валидацию во время выполнения.

В чём разница между interface и type в TypeScript и когда использовать каждый из них?

Используйте interface для публичных, расширяемых объектных контрактов, поскольку он поддерживает declaration merging и, как правило, даёт более чёткие сообщения об ошибках для больших объектных форм. Используйте type для объединений, пересечений, mapped и conditional типов, которые interface не может выразить. Для большой команды практическое правило таково: выберите это соглашение один раз, закрепите его lint-правилом и прекратите дискуссию. Оба компилируются в идентичные проверки типов для простых объектных форм, поэтому выбор касается выразительности и последовательности, а не возможностей.

Делают ли сгенерированные типы из OpenAPI или Prisma валидацию во время выполнения ненужной?

Нет. Сгенерированный тип — это лишь обещание времени компиляции. JSON-ответ, типизированный как User, лишь утверждается соответствующим этой форме; компилятор никогда не проверяет реальную полезную нагрузку во время выполнения, поэтому изменение на бэкенде или null-поле всё равно проскользнёт. Сгенерированные типы предотвращают расхождение фронтенда и бэкенда по контракту, но вам всё равно нужна библиотека схем, такая как Zod или Valibot, для валидации реальной полезной нагрузки на каждой внешней границе. Выводите статический тип из схемы, чтобы одно определение защищало оба слоя.

Как установить и запустить TypeScript 7.0 на стадии Release Candidate?

Установите его командой npm install -D typescript@rc и запускайте стандартный бинарник tsc. В Release Candidate от июня 2026 года нативный компилятор на основе Go переехал из отдельного пакета native-preview в обычный npm-пакет typescript, поэтому для RC больше нет отдельного бинарника tsgo; старые пакеты tsgo и typescript native-preview теперь содержат только ночные сборки. По данным Microsoft, 7.0 нередко работает примерно в десять раз быстрее, чем 6.0, на крупных кодовых базах. Считайте версию и стадию нестабильными данными и проверяйте их актуальность перед переходом.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.