В первой части статьи я описал процесс и причины написания плагина для TypeScript
. Он позволил переопределять настройки из tsconfig для указанных папок, благодаря чему мы начали плавную миграцию на strict. C исходным кодом плагинов — как для IDE
, так и для CLI
— можно ознакомиться по ссылке.
В IDE плагин подсвечивает новые ошибки и корректно отображает nullable
типы, но при запуске проверки типов через консоль он не работает. Те ошибки, что мы видим в IDE
, игнорируются при запуске tsc
, webpack ts-loader
, webpack fork-ts-checker
, vite-plugin-checker
и т.д.
Это логично: из коробки TypeScript
поддерживает только плагины для IDE. Но во время сборки в пайплайне нам обязательно нужно проверять типизацию. Без плагина, включающего строгую типизацию, в основную ветку так и будет попадать «плохой» код.
Во время написания плагина я вспомнил о библиотеке под названием ttypescript. Она позволяет добавлять в TypeScript
плагины, которые поддерживаются при запуске команды tsc
. Когда-то я использовал эту библиотеку в связке с ts-transform-paths.
Зайдя на страницу ttypescript
, я увидел, что библиотека устарела — не поддерживает TypeScript 5
. Её автор советует использовать ts-patch. Хорошо, воспользуемся этой библиотекой.
Эти библиотеки патчат оригинальный TypeScript
, позволяя подключать к нему плагины. Плагины по-разному трансформируют TypeScript
. Как это сможем делать мы, разберёмся позже.
Для начала установим библиотеку командой yarn add ts-patch -D
и запатчим этой библиотекой оригинальный TypeScript
. Это можно сдеать двумя способами:
Live Compiler
. Нужно просто заменить вызовyarn tsc
наyarn tspc
. Если используетсяwebpack ts-loader
илиwebpack fork-ts-checker
, то передать в качествеcompiler
ts-patch/compiler
. Работает только сTypeScript 5
;Persistent Patch
. Нужно выполнитьyarn ts-patch install
. Эта команда заменит оригинальныйTypeScript
в папкеnode_modules
.
Я рекомендую использовать первый вариант — он более явный и не добавляет ненужной магии. Второй вариант сложнее поддерживать. Вы можете просто забыть выполнить эту команду при клониовании проекта, хотя это и решается с помощью scripts.prepare
в package.json
.
Проблемы могут возникнуть и при использовании yarn pnp
, где node-modules
отсутствуют в принципе. Но если вы используете TypeScript
3 или 4 версии, вы сможете патчить только вторым способом.
Набросок плагина
В первой части статьи мы создали 2 yarn workspace
. В первом — код плагина ts-overrides-plugin
(packages/plugin
), а во втором — пример, на котором будем тестировать (packages/example
). Будем придерживаться той же структуры.
После установки библиотеки и замены оригинального компилятора нужно отредактировать example/tsconfig.json
следующим образом:
{
"compilerOptions": {
"plugins": [
"transform": "ts-plugin", // название workspace в packages/plugin/package.json
}
]
}
}
В папке с плагином создадим index.ts
файл и попробуем модифицировать исходную диагностику:
import type { ProgramPattern } from 'ts-patch';
const plugin: ProgramPattern = (program, config, extras) => {
// меняем исходную диагностику
extras.diagnostics[0].messageText = String(`Привет, Хабр! ${extras.diagnostics[0].messageText}`);
// extras.addDiagnostic(...); Можем добавить новый элемент в диагностику
// extras.removeDiagnostic(1); А можем удалить по индексу
return (context) => sourceFile => sourceFile;
};
export default plugin;
Плагин обязательно должен вернуть функцию-трансформер, преобразующую исходные файлы. Файлы оставим в исходном виде, поскольку нам не нужно менять их физически, а только модифицировать диагностику.
Запустим yarn tspc
и увидим модифицированную диагностику:
Проделаем то же самое с webpack
, в котором для проверки типов используется ts-loader
, и запустим yarn webpack
. Увидим, что диагностика никак не изменилась:
Проблема в том, что разные лоадеры и плагины для webpack
по-разному работают с экземпляром TypeScript
, как и в случае с использованием webpack
с fork-ts-checker
. В результате у ts-patch
просто нет доступа к оригинальной диагностике. Подробнее об этой проблеме и её причинах можно почитать в issue.
После нескольких часов анализа исходного кода TypeScript
, ts-loader
и fork-ts-cheker
я решил поменять подход в самом плагине. Самое время вспомнить о разных способах трансформации и разобрать их:
Source Transformer
— если нам нужно трансформировать файлы, работая сAST
-деревом. Этот способ даёт нам и ограниченную возможность модификации диагностики. Он используется по умолчанию.Program Transformer
— если нам нужно изменять результат диагностики, конфигурацию текущего слепкаts.Program
, исключать какие-либо файлы из диагностики. В общем, полностью контролировать работуTypeScript
.
Как выяснялось выше, первый способ нам не подходит. Для реализации нашего плагина потребуется реализовать Program Transformer
, так как нам нужно полностью управлять выводом итоговой диагностики. Немного перепишем tsconfig
:
{
"compilerOptions": {
"plugins": [
{
"transformProgram": true, // указываем `ts-patch` на то, что нужно модифицировать ts.Program
"transform": "ts-plugin", // название workspace в packages/plugin/package.json
}
]
}
}
Также изменится реализация самого плагина:
import type { ProgramTransformer } from 'ts-patch';
import type ts from 'typescript';
const plugin: ProgramTransformer = (program, compilerHost, config, extras) =>
new Proxy(program, {
get: (target, property: keyof typeof program) => {
if (property === `getSemanticDiagnostics`) {
return (sourceFile?: ts.SourceFile) => {
const originalDiagnostic = target.getSemanticDiagnostics(sourceFile);
return originalDiagnostic.map(item => ({
...item,
messageText: `Привет, Хабр! ${String(item.messageText)}`,
}));
};
}
return target[property];
},
});
export default plugin;
С такой реализацией плагин начинает работать в webpack
:
Эта реализация похожа на код плагина для IDE, который мы написали в первой части статьи. Мы также модифицируем метод getSemanticDiagnostics
. В первой части это был метод из ts.LanguageService
, а сейчас мы модифицируем ts.Program
.
Разберём подробнее приведённый выше код, чтобы узнать, какие функции нам доступны:
ProgramTransformer
— функция, возвращающая модифицированный ts.Program
и принимающая следующие параметры:
program
— экземплярts.Program
. Предоставляет нам текущий слепок всего приложения на момент запуска транспиляции. Актуально при запуске из консоли.ts.LanguageService
наоборот — постоянно предоставляет актуальную информацию о файлах при каждом их изменении. Полезно при работе с IDE.ts.Program
предоставляет основные инструменты для работы с кодом: диагностику, подробную информацию о каждм файле и типах.compilerHost
— объект, который определяет, как компилятор взаимодействует с файловой системой и окружением во время процесса компиляции. Он предоставляет методы для чтения, записи, проверки существования файлов и работы с настройками компиляции;config
содержит пользовательские настройки плагина, которые могут быть переданы черезtsconfig.json
. Они позволяют гибко настроить поведение плагина в зависимости от потребностей конкретного проекта;extras.ts
содержит текущий экземплярtypescript
.
Следующая проблема возникает при запуске yarn tspc --watch
. После него мы снова не видим сообщения «Привет, Хабр!». В watch режиме TypeScript
вызывает другой метод для получения диагностики — getBindAndCheckDiagnostics
, а это приватный метод на уровне типизации. При попытке его вызова напрямую, TypeScript
скажет нам, что такого метода нет в ts.Program
. Эта проблема решается хаком на уровне типов. Создадим файл plugin/types/typescript.d.ts
со следующим содержимым:
import { type Diagnostic, type SourceFile } from 'typescript';
declare module 'typescript' {
namespace ts {
interface Program {
getBindAndCheckDiagnostics(sourceFile: SourceFile, cancellationToken?: CancellationToken): Diagnostic[];
}
}
export = ts;
}
После чего внесём изменения в файл index.ts
:
const plugin: ProgramTransformer = (program, compilerHost, pluginConfig, extras) =>
new Proxy(program, {
get: (target, property: keyof ts.Program) => {
if (property === `getSemanticDiagnostics` || property === `getBindAndCheckDiagnostics`) {
return (sourceFile?: ts.SourceFile) => {
const originalDiagnostic = target[property](sourceFile);
return originalDiagnostic.map(item => ({
...item,
messageText: `Привет, Хабр! ${String(item.messageText)}`,
}));
};
}
return target[property];
},
});
Пишем плагин для переопределения настроек tsconfig
Основная наша цель — создать отдельные ts.Program
со своими настройками для каждой переопределённой папки. Как и в случае с плагином для IDE, для начала объявим интерфейс, описывающий настройки плагина:
import type ts from 'typescript/lib/tsserverlibrary';
export interface Override {
files: string[];
compilerOptions: ts.server.protocol.CompilerOptions;
}
export interface PluginConfig {
overrides: Override[];
}
И передадим эти настройки в tsconfig
в example
:
{
"compilerOptions": {
"strict": false,
"plugins": [
{
"transform": "ts-overrides-plugin",
"transformProgram": true,
"overrides": [
{
"files": ["src/modern/**/*.{ts,tsx}"],
"compilerOptions": {
"strict": true,
},
},
]
},
]
},
}
Теперь мы можем получить эти настройки внутри плагина:
const plugin: ProgramTransformer = (program, host, pluginConfig, extras) => {
const { overrides } = pluginConfig as CliPluginConfig;
console.log(overrides);
return program;
};
Далее нам нужно создать отдельный ts.Program
для каждой папки, требующей переопределения настроек TypeScript. Создадим функцию getOverridePrograms
и подробно её разберём:
export const getOverridePrograms = (
rootPath: string,
typescript: typeof ts,
overrides: Override[],
rootFileNames: readonly string[],
defaultCompilerOptions: ts.CompilerOptions,
): ts.Program[] =>
[...overrides].reverse().map(override => {
const isMatch = outmatch(override.files);
const filesToCurrentOverrideDiagnostic: string[] = rootFileNames.filter(fileName =>
isMatch(path.relative(rootPath, fileName)),
);
return typescript.createProgram(filesToCurrentOverrideDiagnostic, {
...defaultCompilerOptions,
...typescript.convertCompilerOptionsFromJson(override.compilerOptions, rootPath).options,
});
});
Функция принимает параметры:
rootPath
— корень нашего проекта;typescript
нужен нам для создания экземпляраts.Program
;overrides
— массив наших переопределений. Для каждого мы создадим отдельныйts.Program
;rootFileNames
— массив файлов проекта;defaultCompilerOptions
— настройки изtsconfig
по умолчанию.
Для начала сделаем reverse
массива overrides
. Это нужно для того, чтобы при поиске через find
мы получали последний ts.Program
, способный обработать файл на наличие ошибок (по аналогии с eslint
).
Затем с помощью функции outmatch
проверим, какие из файлов проекта соответствуют паттернам, указанным в override.files
. Для каждого такого массива файлов создадим новый ts.Program
, передав туда переопределённые настройки компиляции.
Создадим этот массив в корне нашего плагина:
const plugin: ProgramTransformer = (program, host, pluginConfig, extras) => {
const { overrides } = pluginConfig as CliPluginConfig;
const { plugins, ...defaultCompilerOptions } = program.getCompilerOptions();
const rootPath = defaultCompilerOptions.project ? path.dirname(defaultCompilerOptions.project) : process.cwd();
const overridePrograms = getOverridePrograms(
rootPath,
extras.ts,
overrides,
program.getRootFileNames(),
defaultCompilerOptions,
);
return new Proxy(program, {
get: (target, property: keyof ts.Program) => {
if (property === `getSemanticDiagnostics` || property === `getBindAndCheckDiagnostics`) {
// ...
}
return target[property];
},
});
};
Далее, при вызове getSemanticDiagnostics
или getBindAndCheckDiagnostics
из оригинальной ts.Program
, мы хотим:
проверить, нужно ли обработать
sourceFile
переопределённой программой или оригинальной;вызвать соответствующий метод из
overridePrograms
илиprogram
.
И мы бы могли сделать это довольно просто:
return new Proxy(program, {
get: (target, property: keyof ts.Program) => {
if (property === `getSemanticDiagnostics` || property === `getBindAndCheckDiagnostics`) {
return (sourceFile?: SourceFile, cancellationToken?: CancellationToken) => {
const overrideProgramForFile = overridePrograms.find(overrideProgram =>
overrideProgram.getRootFileNames().includes(sourceFile.fileName),
);
return overrideProgramForFile
? overrideProgramForFile[property](sourceFile, cancellationToken)
: target[property](sourceFile, cancellationToken);
};
}
return target[property];
},
Но тут возникает новая проблема: sourceFile
— опциональный параметр и может быть undefined
. В случаях, когда мы хотим получить диагностику сразу по всем файлам проекта, можно просто ничего не передать в этот метод. Так поступают fork-ts-checker
и tsc
в bulid
режиме. Но нам нужно получать разную диагностику для разных файлов — мы не можем обработать все файлы одной ts.Program
. Что можно сделать? Например, пройтись по массиву всех файлов проекта и для каждого отдельно вызвать метод проверки диагностики:
export const getDiagnosticForFile = (
overridePrograms: ts.Program[],
target: ts.Program,
sourceFile: ts.SourceFile,
method: 'getSemanticDiagnostics' | 'getBindAndCheckDiagnostics',
cancellationToken?: ts.CancellationToken,
): readonly ts.Diagnostic[] => {
const { fileName } = sourceFile;
const overrideProgramForFile = overridePrograms.find(overrideProgram =>
overrideProgram.getRootFileNames().includes(fileName),
);
return overrideProgramForFile
? overrideProgramForFile[method](sourceFile, cancellationToken)
: target[method](sourceFile, cancellationToken);
};
const plugin: ProgramTransformer = (program, host, pluginConfig, extras) => {
const { overrides } = pluginConfig as CliPluginConfig;
const { plugins, ...defaultCompilerOptions } = program.getCompilerOptions();
const rootPath = defaultCompilerOptions.project ? path.dirname(defaultCompilerOptions.project) : process.cwd();
const overridePrograms = getOverridePrograms(
rootPath,
extras.ts,
overrides,
program.getRootFileNames(),
defaultCompilerOptions,
);
return new Proxy(program, {
get: (target, property: keyof ts.Program) => {
if (property === `getSemanticDiagnostics` || property === `getBindAndCheckDiagnostics`) {
return (sourceFile?: SourceFile, cancellationToken?: CancellationToken) => {
if (sourceFile) {
return getDiagnosticForFile(overridePrograms, program, sourceFile, property, cancellationToken);
}
return program
.getSourceFiles()
.flatMap(sourceFileItem =>
getDiagnosticForFile(
overridePrograms,
program,
sourceFileItem,
property,
cancellationToken,
),
);
};
}
return target[property];
},
});
};
По итогу мы реализовали плагин, позволяющий получать разную дигностику на основе разных настроек компиляции.
Проблемы
Как и в случае с реализацией плагина для IDE
, с плагином для CLI тоже возникли проблемы.
Память
Мы создаём несколько экземпляров ts.Program
для каждого overrides
. С текущей реализацией оригинальный ts.Program
никак не общается с уже созданными нами ts.Program
. Хотя он уже хранит много информации, которую не хотелось бы заново получать и засорять ей память. Это легко решается путём передачи compileHost при создании новых ts.Program
.
export const getOverridePrograms = (
rootPath: string,
typescript: typeof ts,
overrides: Override[],
rootFileNames: readonly string[],
defaultCompilerOptions: ts.CompilerOptions,
compilerHost?: ts.CompilerHost,
): ts.Program[] =>
[...overrides].reverse().map(override => {
// ...
return typescript.createProgram(
filesToCurrentOverrideDiagnostic,
{
...defaultCompilerOptions,
...typescript.convertCompilerOptionsFromJson(override.compilerOptions, rootPath).options,
},
compilerHost,
);
});
const plugin: ProgramTransformer = (program, compilerHost, pluginConfig, extras) => {
// ...
const overridePrograms = getOverridePrograms(
rootPath,
extras.ts,
overrides,
program.getRootFileNames(),
defaultCompilerOptions,
compilerHost,
);
// ...
};
Потеря глобальных типов
Аналогичная проблема была при реализации плагина для IDE
. Её решение описано в первой части статьи.
Нахождение двух плагинов (для IDE и CLI) в одном пакете
Поскольку в моей библиотеке сразу два плагина, мне нужно было предоставить удобный пользовательский интерфейс для подключения обоих. Это можно сделать с помощью поля exports
в package.json
:
"main": "./dist/ide/index.js",
"exports": {
".": "./dist/ide/index.js",
"./cli": "./dist/cli/index.js"
},
И в tsconfig
подключаем их следующим образом:
{
"compilerOptions": {
"strict": false,
"plugins": [
{
"name": "ts-overrides-plugin",
"transform": "ts-overrides-plugin/cli",
"transformProgram": true,
"overrides": [
{
"files": ["src/modern/**/*.{ts,tsx}"],
"compilerOptions": {
"strict": true,
},
},
]
},
]
},
}
Но такой способ не работает при использовании ts-patch версии ^1.0.0
— эта библиотека не поддерживает модульный экспорт до версии ^2.0.0
. Поэтому пришлось идти на ухищрения и определять, какой из плагинов нужно подключать на основе количества аргументов. В IDE
— это один объект, а в CLI
— три параметра:
import type { ProgramTransformer } from 'ts-patch';
import type ts from 'typescript';
import cliPlugin from './cli';
import idePlugin from './ide';
type Plugin = {
// For IDE plugins
(...args: Parameters<ts.server.PluginModuleFactory>): ReturnType<ts.server.PluginModuleFactory>;
// For CLI plugins
(...args: Parameters<ProgramTransformer>): ReturnType<ProgramTransformer>;
};
const plugin: Plugin = (...args: unknown[]) => {
if (args.length === 1) {
return idePlugin(...(args as Parameters<ts.server.PluginModuleFactory>)) as any;
}
return cliPlugin(...(args as Parameters<ProgramTransformer>)) as any;
};
export = plugin;
Использование files в tsconfig
При реализации плагина и для IDE
, и для CLI
я получал список всех файлов проекта с помощью метода getRootFileName
. Но при использовании параметра files
в tsconfig
этот метод возвращает только файлы, указанные в этом параметры. Теперь я не могу создавать ts.Program
, который охватывает все файлы проекта. Проблему подсветил @mrShadow, пока она не решена.
Использование с typescript-eslint
И эту проблему подсветил @mrShadow. У typescript-eslint
есть правила, которые корректно работют только со включённой строгой типизацией. Например, @typescript-eslint/no-unnecessary-condition
.
Проблема в этой строке — typescript-eslint
извлекает параметры комплятора из исходной программы. В теории её можно решить, передав пользовательский ts.Program в параметрах парсера (пример). Но проблема остаётся. Предоставить различные параметры компилятора для разных файлов невозможно.
Но и тогда он правильно работает только в CLI
. Для корректной работы в IDE
требуется languageService
, а не ts.Program
. typescript-eslint
имеет параметр конфигурации, который позволяет использовать плагины TypeScript
в сочетании с languageService
. Но сейчас я просто… застрял — TypeScript
выдаёт ошибки о конфликтующих экземплярах программ при попытке изменить файл в IDE
(пример).
Что дальше?
Я собираюсь развивать этот плагин: исправлять баги, описанные выше, оптимизировать его под нужды различных проектов. Всем читателям желаю никогда не переводить проект на strict
постфактум :)