Compiler API, ts-morph и кодмоды: автоматизация над кодовой базой
Рано или поздно наступает задача, которую нельзя решить ни типом, ни ревью:
«переименовать поле во всех 3 000 файлов», «запретить process.env вне
config.ts», «проставить явные типы возврата у всех экспортов», «перевести
кодовую базу с enum на as const». Руками — недели и неизбежные пропуски.
Регулярками — быстро, но неверно: текст не знает про области видимости, строки и
комментарии.
Правильный инструмент — тот же, что уже стоит в проекте. TypeScript публикует Compiler API: вы получаете полноценное дерево разбора и, что важнее, доступ к чекеру типов. Это превращает разовый героизм в скрипт на сто строк.
Как устроен компилятор
текст в токены"] SC --> PA["Parser:
токены в AST"] PA --> BI["Binder:
имена в символы,
области видимости"] BI --> CH["Checker:
вывод и проверка типов"] CH --> DI["Диагностика:
ошибки и подсказки"] CH --> TR["Transformer:
стирание типов, понижение синтаксиса"] TR --> EM["Emitter:
.js, .d.ts, .map"] CH -.через Language Service.-> ED["Редактор:
hover, rename, quick fix"]
Ключ к пониманию — разделение уровней:
- AST (Parser) отвечает на вопрос «как это написано»:
Identifier,CallExpression,PropertyAccessExpression. Синтаксис, ничего больше. - Символы (Binder) отвечают на вопрос «на что ссылается это имя»: связывают использование с объявлением, даже через реэкспорты.
- Типы (Checker) отвечают на вопрос «что это по сути»:
string | undefined, результат вывода дженерика, разрешённая перегрузка.
Кодмод, работающий только на AST, — это умный sed. Кодмод, спрашивающий чекер, —
инструмент, который отличает user.id (тип UserId) от session.id (тип string)
и правит только то, что нужно.
Каждый узел — объект с полями kind (значение перечисления ts.SyntaxKind), pos
и end (границы в исходном тексте), ссылками на детей. Прежде чем писать код,
откройте TypeScript AST Viewer и посмотрите, во что
разбирается интересующая вас конструкция: это экономит часы.
Собственный анализатор на Compiler API
Начнём с задачи из прошлой главы: найти экспортируемые функции без явного типа возврата — они замедляют компиляцию и делают публичный контракт неявным.
// scripts/find-implicit-returns.ts
import ts from "typescript";
// 1. Собираем программу так же, как это делает tsc: из tsconfig
const configPath = ts.findConfigFile(process.cwd(), ts.sys.fileExists, "tsconfig.json");
if (!configPath) throw new Error("tsconfig.json не найден");
const { config } = ts.readConfigFile(configPath, ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(config, ts.sys, process.cwd());
const program = ts.createProgram(parsed.fileNames, parsed.options);
const checker = program.getTypeChecker(); // доступ к типам — главная ценность
let found = 0;
for (const sourceFile of program.getSourceFiles()) {
// пропускаем чужой код и файлы деклараций
if (sourceFile.isDeclarationFile || sourceFile.fileName.includes("node_modules")) continue;
// 2. Обходим дерево рекурсивно
ts.forEachChild(sourceFile, function visit(node) {
if (ts.isFunctionDeclaration(node) && node.name && !node.type) {
const isExported = node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
if (isExported) {
// 3. Спрашиваем чекер, что компилятор вывел сам
const signature = checker.getSignatureFromDeclaration(node);
const returnType = signature
? checker.typeToString(checker.getReturnTypeOfSignature(signature))
: "unknown";
const { line } = sourceFile.getLineAndCharacterOfPosition(node.name.getStart());
console.log(`${sourceFile.fileName}:${line + 1} ${node.name.text} -> ${returnType}`);
found++;
}
}
ts.forEachChild(node, visit);
});
}
console.log(`Всего: ${found}`);
process.exit(found > 0 ? 1 : 0); // можно поставить гейтом в CI
Сто строк — и у вас собственная проверка, которой нет ни в одном линтере. Тот же
каркас годится для инвентаризации: сколько в проекте as any, какие эндпоинты не
покрыты схемой, какие доменные модули импортируют инфраструктуру в обход портов
из главы про архитектуру.
Чекер умеет и большее — например, отдать все диагностики программы, как это делает
tsc --noEmit:
const diagnostics = ts.getPreEmitDiagnostics(program);
for (const d of diagnostics) {
console.log(ts.flattenDiagnosticMessageText(d.messageText, "\n"));
}
Кодмоды на ts-morph
Compiler API прекрасен для чтения, но неудобен для записи: AST иммутабелен, изменения делаются через трансформеры и фабрики узлов. Для правок берут ts-morph — обёртку, которая даёт мутабельный API поверх того же компилятора и сама печатает результат обратно в файл.
// scripts/codemod-enum-to-const.ts
import { Project, SyntaxKind } from "ts-morph";
const project = new Project({ tsConfigFilePath: "tsconfig.json" });
for (const file of project.getSourceFiles("src/**/*.ts")) {
for (const enumDecl of file.getEnums()) {
// Берём только строковые enum: их безопасно заменить объектом as const
const members = enumDecl.getMembers().map((m) => ({
name: m.getName(),
value: m.getInitializer()?.getText(),
}));
if (members.some((m) => m.value === undefined || !m.value.startsWith('"'))) continue;
const name = enumDecl.getName();
const isExported = enumDecl.isExported();
const body = members.map((m) => ` ${m.name}: ${m.value},`).join("\n");
// Заменяем объявление: объект as const + одноимённый тип-union
enumDecl.replaceWithText(
`${isExported ? "export " : ""}const ${name} = {\n${body}\n} as const;\n\n` +
`${isExported ? "export " : ""}type ${name} = (typeof ${name})[keyof typeof ${name}];`,
);
}
}
await project.save(); // записываем изменения на диск
Отдельно стоит безопасное переименование: ts-morph умеет дёргать Language Service, тот самый, что стоит за «Rename Symbol» в редакторе. Оно учитывает области видимости и реэкспорты, а не совпадение подстроки:
const prop = project
.getSourceFileOrThrow("src/domain/user.ts")
.getInterfaceOrThrow("User")
.getPropertyOrThrow("userName");
prop.rename("name"); // обновит ВСЕ использования во всей программе
await project.save();
Ещё несколько задач, которые решаются в пару десятков строк: расставить
import type там, где импорт используется только в типах; добавить override
после включения noImplicitOverride; заменить относительные импорты на subpath
imports из главы про модули;
сгенерировать фикстуры по интерфейсам.
Альтернативы: jscodeshift — исторический стандарт для JS-кодмодов (React публикует свои миграции именно так), но он работает без типов; ast-grep — очень быстрый поиск и замена по синтаксическим шаблонам, отличный выбор, когда типы не нужны.
Раскатка кодмода без боли
Механическое изменение тысячи файлов — это риск. Процесс, который работает:
Почему именно так:
- Скрипт коммитится отдельно — ревьюер читает сто строк логики, а не десять тысяч строк диффа.
- Прогон кодмода — отдельный коммит без ручных правок. Тогда изменение воспроизводимо: результат можно получить заново из предыдущего коммита.
- Форматирование — своим коммитом, иначе шум перекроет смысл.
- Гейт — typecheck и тесты, а не чтение диффа глазами. Именно ради этого момента писались тесты в главе про тестирование.
- Мержить целиком и быстро: кодмод конфликтует со всеми открытыми ветками, и чем дольше он живёт, тем дороже.
Для очень больших кодовых баз применяют то же по частям: кодмод, умеющий работать с подкаталогом, прогоняется помодульно, каждый прогон — свой PR.
Правила ESLint с доступом к типам
Кодмод меняет код один раз. Чтобы правило соблюдалось дальше, нужен линтер.
typescript-eslint даёт правилам доступ к чекеру — это позволяет писать проверки,
невозможные в обычном ESLint.
// tools/eslint-rules/no-raw-env.ts
import { ESLintUtils, type TSESTree } from "@typescript-eslint/utils";
const createRule = ESLintUtils.RuleCreator(
(name) => `https://wiki.acme.dev/eslint/${name}`,
);
export const noRawEnv = createRule({
name: "no-raw-env",
meta: {
type: "problem",
docs: {
description:
"process.env читается только в config.ts, где значения валидируются схемой",
},
messages: {
rawEnv: "Не читайте process.env напрямую — используйте импорт из config.ts",
},
schema: [],
},
defaultOptions: [],
create(context) {
// в config.ts правило не действует
if (context.filename.endsWith("/config.ts")) return {};
return {
// ищем узел вида process.env
"MemberExpression[object.name='process'][property.name='env']"(
node: TSESTree.MemberExpression,
) {
context.report({ node, messageId: "rawEnv" });
},
};
},
});
Пример с типовой информацией — правило, запрещающее передавать доменные сущности в HTTP-ответ напрямую (нарушение границы DTO из главы про контракты):
create(context) {
const services = ESLintUtils.getParserServices(context); // доступ к чекеру
return {
CallExpression(node) {
const arg = node.arguments[0];
if (!arg) return;
const type = services.getTypeAtLocation(arg);
const name = type.getSymbol()?.getName();
if (name && name.endsWith("Entity")) {
context.report({ node: arg, messageId: "entityLeak" });
}
},
};
}
Правила подключаются как локальный плагин (плоский конфиг ESLint позволяет
подключить объект прямо из файла), и с этого момента конвенция команды перестаёт
быть строчкой в вики и становится проверкой в CI. Дополните её --fix-функцией,
если исправление механическое, — тогда правило само чинит код.
Генерация кода: когда она оправдана
Кодогенерация — родственник кодмода: тот же AST, только на выходе новый файл.
Оправданные случаи мы уже видели: типы из
схемы API, клиент БД из схемы
(Prisma), .d.ts для публикуемого пакета. Общий признак: есть внешний источник
правды, а код — его производная.
Неоправданные: генерация «шаблонного кода», который потом правят руками (при следующей генерации правки потеряются), и генерация того, что элементарно выражается дженериком или функцией. Прежде чем писать генератор, проверьте, не решается ли задача обычной абстракцией.
Правила гигиены для генерируемого кода: отдельный каталог src/generated, баннер
// Сгенерировано автоматически, не редактировать в шапке, исключение из линтера
и из ревью (.gitattributes с linguist-generated=true), а также проверка
«результат генерации совпадает с закоммиченным» в CI.
Карта задач: чем что решать
над кодовой базой)) Разовое изменение Rename Symbol в редакторе ast-grep для простых шаблонов Кодмод на ts-morph при большом объёме Постоянное правило Правило typescript-eslint с типами Гейт на Compiler API в CI Проверка архитектурных границ Производный код Типы из схемы API Клиент БД из схемы Файлы деклараций пакета Инвентаризация Счётчик any и ts-expect-error Поиск мёртвого кода Отчёт по границам слоёв
Когда автоматизация не нужна
Честный список ситуаций, где скрипт дороже ручной работы:
- Правок меньше сотни и они не повторяются —
Rename Symbolв редакторе быстрее. - Изменение требует понимания смысла (переименование по домену, разделение функции) — автоматика сделает механически неверно.
- Конструкция редкая и разнообразная — вы потратите время на разбор частных случаев, которых пять.
- Кодмод придётся поддерживать: разовый скрипт живёт в PR и умирает вместе с ним, и это нормально. Не превращайте его в «платформу».
Ориентир: если изменение затрагивает больше двухсот мест или должно повторяться регулярно — пишите скрипт. Если меньше и разово — делайте руками.
Типичные ошибки
- Регулярки вместо AST. Ломают строки, комментарии, шаблонные литералы и совпадения в чужих названиях.
- Кодмод без прогона форматтера — гигантский шумный дифф.
- Смешать кодмод и ручные правки в одном коммите — невозможно перепроверить.
- Игнорировать
.d.tsиnode_modulesпри обходе программы — скрипт будет идти вечно и найдёт чужое. - Использовать
getText()вместо чекера, чтобы «понять тип» — это снова текстовая эвристика. - Оставить правило только в вики. Если конвенцию не проверяет CI, она соблюдается ровно до следующего аврала.
Источники
- TypeScript Wiki: Using the Compiler API —
канонические примеры работы с
ProgramиTypeChecker. - ts-morph документация — навигация и мутации AST.
- TypeScript AST Viewer — интерактивный разбор кода в узлы.
- typescript-eslint: Custom Rules — как писать правила, в том числе с типовой информацией.
- jscodeshift и ast-grep — альтернативные инструменты кодмодов.
Итог продвинутого блока
Пять последних глав закрывали то, что отделяет «пишу на TypeScript» от «веду на TypeScript большую кодовую базу»: как типы и код путешествуют между пакетами (модули и декларации); как устроена объектная механика языка и его декораторы (классы); как не дать контракту между сервисами разъехаться (API-контракты); как затащить TypeScript в живой проект и не утонуть в компиляции (миграция и производительность); и как применять решения ко всей кодовой базе разом — эта глава.
Сквозная мысль всего курса не изменилась: типы — это инструмент этапа разработки. Они стираются, поэтому границы валидируются в рантайме, контракты генерируются из одного источника, а конвенции проверяются машиной, а не человеком.
Что дальше
Курс пройден целиком. Вернитесь к обзору и дорожной карте, чтобы освежить любой раздел. Дальше логично углубиться в смежные треки: фронтенд-архитектуру — если ваш TypeScript живёт в браузере; тестирование — чтобы выстроить стратегию проверок; безопасность цепочки поставок — потому что npm-зависимости остаются главной поверхностью атаки любого TypeScript-проекта.