Aviasales Monorepo: TypeScript Project References



В тайпскрипте достаточно давно существуют Project References.



Идея в том, чтобы разбить одно большое приложение на несколько маленьких частей. У каждой такой части должен быть свой tsconfig.json, а связываются они помимо import’ов в приложении ещё и через поле references.



В таком режиме алгоритм тайпскрипта меняетcя: вместо самого модуля импортируется его декларация (она же d.ts). Так же тайпскрипт собирает граф зависимостей, используя референсы, и компилирует их рекурсивно.



Долгое время пример в документации (там разделяют тесты и исходники) мне казался синтетическим и особо не имеющим смысла. Более того, не очень понятно зачем оно нужно, когда есть incremental режим.



Но вот в монорепо эта опция заиграла новыми красками!



Как я упомянал ранее, у нас есть как независимые пакеты, так и связанные друг с другом. И если с независимыми всё просто — написал tsc и всё работает, то со связанными всё немного сложнее.



Если начать компилировать пакет, зависящий от других пакетов в монорепо, то получим ошибку в духе «пакет не скомпилирован, d.ts не вижу, без понятия какие там у тебя типы, exit code 1». Получается, что нужно сперва скомпилировать все зависимые пакеты. Это можно сделать через yarn workspaces foreach и флаг --topological или --recursive. Но получается, что нужно как-то фильтровать пакеты, если у нас монорепо на 100 пакетов, а работать нужно с 3-мя. В общем, не очень удобно получается.



И тут на помощь приходят те самые референсы!

Напомню, мы связываем пакеты через workspace-протокол. Идея была проста: на основе этих связей сгенерировать конфиг содержащий поле references, отнаследоваться от него и всё заработает автомагически.



Но тут начались весёлые нюансы.



Ручками указывать референсы не хотелось, т.к. есть риск, что кто-то забудет добавить пакет и всё сломается. Ну и в целом если можно автоматизировать, то почему бы и нет? На тот момент мы решили взять бета версию 5-го тайпскрипта, т.к. в ней extends поддерживает массив конфигов. Удобно: указываем базовый конфиг, конфиг проекта и конфиг с референсами. При этом конфиг с референсами всегда генерируем с нуля и просто перезаписываем, а в базовый и проектный конфиги можно вносить изменения ручками — они сохранятся.



Первым делом отвалился vitest. Он использует vite (очевидно, хех), а уже в нём есть tsconfck для работы с конфигами тайпскрипта. И он на тот момент не поддерживал массивы в extends. В целом это было не очень страшно, т.к. пул-реквест с этой фичей уже был готов и мы были готовы подождать.



А вот вторая проблема оказалась похуже. Дело в том, что extends в тайпскрипте игнорирует одно единственное свойство. Какое? Конечно же references! Удобно, хех. Самое смешное, что я убил на дебаг пару часов, думая что мы как-то не так указываем пути или ещё что-то, пока не додумался запустить тайпскрипт с опцией --showConfig и увидел, что нет у меня там никаких references.



В общем, от идеи экстендить массив конфигов пришлось отказаться. Как итог, у нас получилась традиционная схема: base.tsconfig.json → project.tsconfig.json → tsconfig.json. Из них только tsconfig.json генерируется и содержит референсы. Ну и само собой, проверяем на CI, что конфиги в актуальном состоянии.



Оставалось поменять запуск компиляции:

вместо простого tsc теперь вызывается tsc --build path/to/package — TS находит зависимости, компилирует их и всё работает. Ну и конечно же оно поддерживает watch-режим.



Осталось разобраться почему поле references не наследуется.

Судя по всему, так сделано из идеи, что в корневом tsconfig принято указывать референсы на все под-проекты и таким образом компилировать всё приложение из корня. На мой взгляд это странное решение и правильней было бы наследовать все поля, и в таком случае заставлять пользователей явно переопределять референсы. Ну, это на мой взгляд, что ж. Поменять поведение вряд ли получится из-за обратной совместимоси, но я решил хотя бы написать ишью о том, что неплохо было бы подсветить везде, где упоминается references, что наследовать его нельзя.