Performance и оптимизация TypeScript-типов в больших проектах

от автора

 Photo by Carl Heyerdahl on Unsplash

Photo by Carl Heyerdahl on Unsplash

Большие TypeScript-проекты на практике чаще всего представляют собой монорепозитории (монорепы), в которых может быть сотни и даже тысячи модулей, интерфейсов и типов. На ранних этапах роста всё кажется вполне управляемым, но в определённый момент мы начинаем замечать, что время компиляции становится слишком большим, а IDE (например, VS Code) начинает работать ощутимо медленнее.

В этой статье мы рассмотрим, почему TypeScript захлёбывается в крупных проектах, какие подходы и практики помогут оптимизировать типы и как проводить диагностику узких мест в процессах компиляции и разработки.

Почему TypeScript начинает тормозить в больших проектах?

Рекурсивные типы и глубокие структуры

TypeScript поддерживает сложные и весьма гибкие механизмы для описания типов. Это невероятная мощь, но она же может обернуться и слабым местом. Чем глубже и хитрее иерархия наследования, тем больше усилий TypeScript приходится тратить на вычисление итоговых типов.

Пример потенциально сложного рекурсивного типа (упрощённый):

type Nested<T> = {   value: T;   next?: Nested<T>; }; 

Если в коде появится много зависимостей от Nested, TypeScript будет вынужден проходить по всей цепочке рекурсивного типа каждый раз, когда нужно вычислить или вывести тип. Это приводит к росту времени компиляции (и времени отклика IDE).

Раздутая структура импортов

При использовании глобальных импортов в каждом файле (особенно в монорепах) компилятору приходится обрабатывать огромное число связей между файлами. Нередко встречается ситуация, когда большая часть модулей импортирует типы из одних и тех же файлов «со всеми типами сразу». Это также сильно замедляет процесс компиляции, так как TypeScript приходится многократно анализировать одни и те же декларации.

Избыточная проверка типов при использовании монорепы

В монорепах часто существуют внутренние библиотеки, модули и пакеты, которые переиспользуются в разных местах. При этом общий tsconfig может собирать весь проект целиком. Так, любая небольшая правка в одном из проектов может вызывать повторную компиляцию во всём репозитории, даже если прямых зависимостей не так уж много.

Высокая сложность generic-типов

Использование сложных обобщённых типов (generics), особенно вместе с условными типами (conditional types) и mapped types, может многократно повысить нагрузку на процессор во время компиляции. Когда типы зависят от нескольких других типов, включающих в себя условные конструкции и рекурсию, сложность проверки типов растёт экспоненциально.

Методики диагностики

—extendedDiagnostics

TypeScript предлагает специальные флаги для диагностики производительности компиляции. Самый простой способ понять где болит это запустить:

tsc --extendedDiagnostics

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

На что особенно обратить внимание:

  • Check time – время, затраченное на проверку типов. Если оно слишком велико, значит в проекте есть тяжёлые типы.

  • Memory used – если память уходит в потолок, возможно, что в проекте слишком много связанных типов или есть циклические зависимости.

Более подробно про эти параметры можно почитать тут.

Инструменты командной строки

Помимо --extendedDiagnostics, иногда могут помочь системные утилиты для анализа использования CPU и памяти. Например:

  • Linux/Mac: top, htop, time, perf (для более низкоуровневого анализа).

  • Windows: Диспетчер задач, Resource Monitor, perfmon.

Они помогают понять, насколько компиляция TypeScript нагружает вашу систему в целом.

Логи TypeScript-сервера (tsserver) в IDE

В VS Code или других редакторах можно включить логирование TypeScript-сервера:

  1. Откройте «Выходная консоль» (Output).

  2. Выберите «TypeScript» из выпадающего списка.

  3. В settings.json установите typescript.tsserver.trace = "verbose".

Логи будут довольно детальными. Часто удаётся заметить, на каких запросах TypeScript зависает дольше всего, какие файлы или операции вызывают рост времени проверки. Однако это потребует вручную анализировать логи, что не всегда просто.

tsc —generateTrace (TypeScript 4.4+)

Начиная с TypeScript 4.4, появился экспериментальный флаг --generateTrace, который создаёт подробный трейс компиляции (включая проверку типов). Пример использования:

tsc --generateTrace traceDir --project tsconfig.json

В результате в папке traceDir окажется несколько .trace.json файлов, которые можно открыть специальными инструментами (например, Trace Event Profiling Tool или Perfetto). Там видны этапы анализа типов и можно отследить, какие именно участки кода занимают больше времени.

Способы оптимизации

Разделение на несколько tsconfig.json

В больших монорепозиториях имеет смысл дробить проект на несколько субпроектов, каждый со своим tsconfig.json. Это позволяет:

  • Локализовать проверки типов только в пределах одного пакета или модуля.

  • Ускорить сборку, так как TypeScript не будет анализировать весь репозиторий сразу.

  • Кэшировать результаты сборки: например, если ничего не менялось в модуле, он не пересобирается.

Пример структуры монорепы:

/project   /packages     /core       tsconfig.json       src/...     /utils       tsconfig.json       src/...     /app       tsconfig.json       src/...   tsconfig.json

В корневом tsconfig.json у вас может быть общая конфигурация (базовая), а в каждом пакете — свой tsconfig.json, который extends от базовой конфигурации. Если правильно настроить пути (paths) и зависимости, то компиляция будет происходить пакет за пакетом, а не всё сразу.

Отделение процесса проверки типов от сборки (удвоенный pipeline)

В крупных TypeScript-проектах встречается подход, когда процесс сборки (транспиляции) и проверки типов разводят на разные шаги. Идея в том, что при локальной разработке в режиме горячих перезапусков иногда выгодно не блокировать сборку на проверке типов — так её можно выполнять параллельно или вообще отдельно.

Как это работает:

  1. Сборка (через Babel или esbuild):

    • TypeScript-файлы просто транслируются в JavaScript без проверки типов.

    • По сути, это аналог allowJs: компилятор/сборщик не останавливается на типовых ошибках.

    • Можно настроить кэширование результатов сборки (например, Babel кеширует скомпилированные файлы).

  2. Проверка типов:

    • Запускается tsc --noEmit (или tsc --emitDeclarationOnly), чтобы проверить и/или сгенерировать декларации.

    • Этот шаг может выполняться:

      • В отдельном процессе параллельно с горячей сборкой.

      • В CI/CD-пайплайне или через pre-commit/pre-push хуки.

Таким образом, есть два независимых pipeline:

  • Один даёт быстрые результаты для runtime (что важно для локальной разработки),

  • Второй гарантирует, что проект «не сломан» по типам.

Параллельная компиляция

Сам TypeScript не умеет многопоточно проверять типы (пока что 🙂). Однако вы можете запускать сборку в параллель на уровне если у вас проект организован как монорепозиторий и вы используете инструменты вроде NX, Turborepo, Lage или Rush.

  • Если несколько подпроектов не зависят друг от друга, они могут собираться одновременно, загружая несколько ядер процессора.

  • Если зависят — строятся в правильном порядке, но всё равно параллелится то, что можно.

В итоге на мощных многоядерных машинах это даёт хороший прирост к скорости, особенно когда у вас десятки и сотни пакетов в монорепе.

Ограничение глубины рекурсии типов

TypeScript имеет специальный флаг --maxNodeModuleJsDepth и схожие внутренние механизмы, но иногда вы сами можете ограничить глубину рекурсии в своих типах. Если у вас есть очень сложные рекурсивные generics, попробуйте:

  • Заменить рекурсию на плоские типы.

  • Вынести часть логики на runtime-уровень (в код), а не на типизацию.

  • Использовать шаблонные типы менее активно, оставляя всю рекурсию в минимально необходимой форме.

Условно говоря, вместо:

type DeepReadOnly<T> = {     readonly [P in keyof T]: DeepReadOnly<T[P]>; };

Можно местами использовать простые Readonly<T> и аккуратно комбинировать их. Да, это уменьшит строгую типизацию в глубине структуры, но заметно ускорит сборку.

Избегайте круговых (циклических) зависимостей

Если у вас есть файл A.ts и файл B.ts, которые импортируют типы друг у друга, TypeScript будет вынужден выстраивать такие зависимости в несколько проходов. В крупных проектах могут появляться очень запутанные циклы:

graph LR A --> B B --> C C --> A

Кажется, что всё работает, но такой круг вреден для производительности. Это сложно реорганизовать, но если вы видите, что в проекте есть циклы, постарайтесь выделить общие типы в отдельный модуль (вроде common-types.ts), чтобы каждая часть проекта зависела только от него, а не друг от друга.

Локальный импорт только нужного

Если вам нужна пара интерфейсов из большого модуля SomeHugeTypes.ts, целесообразнее иногда вынести нужные интерфейсы в отдельные файлы. Тогда и импортировать вы будете лишь небольшие, локально сфокусированные типы. Это ускоряет анализ, так как компилятору не придётся парсить и резолвить лишний код.

Например, вместо:

import {    IBigInterface1,    IBigInterface2,    IBigInterface3,    // ...   IBigInterface100  } from '../some-huge-module'; 

Создайте some-huge-module/sub-interface.ts с только нужными типами:

// sub-interface.ts export interface IBigInterface1 { /* ... */ }

И импортируйте:

import { IBigInterface1 } from '../some-huge-module/sub-interface';

Хотя такая структура может немного усложнить архитектуру, она помогает снизить время компиляции в крупных проектах.

Настройка skipLibCheck и incremental

В tsconfig.json есть флаги, позволяющие ускорить сборку:

  • skipLibCheck: true — пропускает проверку типов в файлах деклараций .d.ts (например, в node_modules). Это не влияет на точность проверки в вашем коде, но ускоряет процесс, особенно если у вас много зависимостей.

  • incremental: true — включает механизм инкрементальной компиляции. При повторных сборках TypeScript использует кэш, и если ничего не изменилось, файлы не перепроверяются повторно.

{   "compilerOptions": {     "incremental": true,     "skipLibCheck": true,     "strict": true,     // ...   } } 

«Диета» для node_modules

Помимо skipLibCheck, стоит регулярно «чистить» и оптимизировать окружение, чтобы уменьшить объём анализируемых деклараций:

  • Определять typeRoots в tsconfig.json, чтобы ограничить, откуда берутся декларации. Это поможет избежать автоподхвата ненужных типов.

  • Удалять неиспользуемые @types пакеты. Если библиотека больше не используется, её типы могут оставаться в node_modules, и компилятор их всё равно сканирует.

  • Исключать дубликаты зависимостей. В больших проектах может оказаться, что один и тот же пакет стоит в нескольких версиях. Попробуйте свести всё к одной версии или используйте менеджеры (Yarn Berry, pnpm, NX, Lerna и т.д.) для унификации установок.

Все эти меры позволяют уменьшить объём кода, который приходится анализировать TypeScript, и тем самым ускорить процесс проверки типов.

Использование isolatedModules

Если вы используете Babel или esbuild для преобразования TypeScript в JavaScript, вы можете включить isolatedModules:

{   "compilerOptions": {     "isolatedModules": true     // ...   } } 

Это вынуждает TypeScript проверять каждый файл изолированно, без агрессивного анализа соседних модулей. Однако это накладывает определённые ограничения на то, как вы используете определённые конструкции (например, нельзя делать объединённый экспорт export = при isolatedModules: true). Зато компиляция часто становится быстрее, особенно в больших проектах.

Сократить патчи глобальных типов

Многие люди любят переопределять или расширять стандартные глобальные типы (через declare global). Это удобно, но для большого проекта становится расширением для всего кода, а значит, оно повсюду учитывается при проверке. Если таких патчей много, они могут замедлить работу компилятора. Иногда лучше явно импортировать (или экспортировать) новые типы из локальных модулей, вместо того чтобы загрязнять глобальный скоуп.

Заключение

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

С другой стороны, есть и хорошие новости: инструментов для диагностики довольно много — от --extendedDiagnostics до внутренних профилировщиков в VS Code и внешних утилит.
И есть проверенные подходы к оптимизации: деление на субпакеты, ограничение рекурсии, только нужный импорт, настройка skipLibCheck, incremental и т.д.

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

Совет: Относитесь к сложным generic-типам как к любому скрытому коду: да, это всего лишь типы, но они тоже требуют ресурсов, и чем они сложнее, тем больше времени уходит на их проверку. Оптимизация, упрощение и логическая сегрегация — ключи к тому, чтобы ваш большой TypeScript-проект оставался быстрым и удобным в работе.

Если вам интересны дополнительные материалы по TypeScript и JavaScript, рекомендую прочитать мои статьи на Хабре:


ссылка на оригинал статьи https://habr.com/ru/articles/871436/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *