Привет, Хабр! Меня зовут Дима, я Head of Frontend в Dodo Engineering. Моя команда создаёт инструменты для удобной работы с фронтендами, унифицирует подходы к разработке, помогает другим командам в создании удобных пользовательских интерфейсов Dodo IS.
Недавно мне срочно понадобилось написать плагин для TypeScript. Начал я с того, что погуглил, как это сделать. По пути боролся с повышенным потреблением памяти и искал недостающие файлы в массиве, переписывал Proxy и не только, а закончил на... А впрочем это вы узнаете в конце.
Чтобы засинхрониться, оставляю вам в начале статьи ссылку на код плагина. Открывайте его и давайте вместе разбираться, зачем мне вообще понадобился плагин и как я его писал.
Почему решил написать?
TypeScript — странный язык. Он помогает разработчикам, но при этом вызывает много вопросов. Он добавляет статическую типизацию в JavaScript, что позволяет IDE заранее «ловить» ошибки и подсказывать разработчику, как лучше писать код. Это делает код более предсказуемым и помогает избежать множества багов на этапе написания.
Однако у TypeScript есть обратная сторона. Строгая типизация усложняет написание кода, особенно тем, кто привык к гибкости JavaScript. Порой описание типов занимает больше времени, чем решение задачи. Некоторые разработчики осознанно отказываются от TypeScript в своих проектах по этой причине.
Кроме того, TypeScript — это как дополнительный слой в разработке. Сам по себе он не может работать в браузере или на сервере, его сначала нужно преобразовать в обычный JavaScript. И хотя TypeScript помогает писать более чистый код, в итоге весь процесс выглядит лишним шагом, причём не всегда оправданным.
Тем не менее TypeScript в каком-то смысле является дополнением к тестам и документации, особенно в больших проектах, но только если его правильно настроить. Одна из самых важных настроек в TypeScript — опция strict
. Она заслуживает особого внимания, когда речь идёт о масштабных проектах. Эта опция включает в себя несколько других.
strictNullChecks
Одна из самых главных опций. Без неё все | null | undefined
(nullable) типы просто игнорируются:
function findUserName(): string | null {
return null; // здесь может быть обращение к бэкенду
}
const userName = getUserName();
console.log(userName.toUpperCase()); // без включённого strict мы не увидим ошибку в IDE, хотя userName может оказаться null. А вот в рантайме упадём с ошибкой
noImplicitAny
function stringToUpper(name) { // с включённым strict TypeScript не позволит передать любой аргумент в name, запретит неявный any-тип
return name.toUpperCase();
}
stringToUpper(42); // здесь мы упадём с ошибкой в рантайме
Существует ряд других опций, которые также критичны, но требуют более детального рассмотрения. Чтобы не увеличивать объём статьи, я приведу их список со ссылками на официальную документацию:
Но что делать, если на этапе зарождения проекта strict
не был включён? Ведь по началу может казаться, что это лишняя нагрузка: проект небольшой, всё под контролем, и строгая типизация кажется избыточной. К тому же переводить проект с JavaScript на TypeScript намного проще с выключенным strict
— просто переименовать .js
файлы в .ts
.
Но когда проект вырастает до значительных размеров, исправлять эти ошибки становится всё сложнее. И если команда решит на каком-то этапе включить strict
, она столкнётся с тем, что нужно переписывать огромные участки кода, чтобы удовлетворить требованиям строгой типизации. Это может сильно затормозить разработку и усложнить поддержку кода.
С такой проблемой я и столкнулся на одном из проектов. Несколько лет назад мы перевели его с JavaScript на TypeScript, не включив опцию strict
. Возможно, мы сделали это случайно — в пустом tsconfig
она отключена по умолчанию, а возможно осознанно — из-за описанных выше причин. Так или иначе, команда начала сталкиваться с проблемами — в проде начали вылезать ошибки, связанные со strictNullChecks
. Дело было в переменных. В коде они были нулевыми, но в какой-то момент nullable терялся, так как IDE и TypeScript никак об этом не предупреждали.
В проекте больше 3000 файлов. Простое включение strict
подсветило сотни ошибок. Их исправление заняло бы недели, а может и месяцы. Возник вопрос: а как перевести только часть проекта, содержащую критичную функциональность? К сожалению, TypeScript не позволяет переопределять tsconfig
для определённых папок, так как он воспринимает весь код как целостный проект, где друг с другом связан каждый файл и каждый тип. У TypeScript есть issue на эту тему, открытый в 2019 году.
Одно из решений: раздробить один монолит-проект на мелкие части, workspaces. И уже в этих частях внедрять strict
. Но из-за тесной связанности компонентов проекта, эта задача заняла бы ещё больше времени. Хоть мы и смогли собрать низко висящие фрукты — выделили независящие от других частей проекта утилиты и UI-компоненты — этого всё равно было мало. Оставалась потребность переопределять tsconfig
только для определённых частей проекта и постепенно переводить на strict
.
В какой-то момент я вспомнил о возможности писать свои плагины для TypeScript. И пошёл разбираться…
Плагины для IDE
По запросу «TypeScript plugin» в гугле, мы попадаем на официальный гайд по написанию плагина. Это не самый подробный гайд. Он задаёт только базовый вектор в создании плагинов. Поэтому я как слепой котёнок вооружился исходниками TypeScript, IDE, и пошёл писать плагин.
Быстрый набросок плагина
Первым делом я создал два workspace. В одном из них находится код моего плагина (packages/plugin
), а в другом (packages/example
) — этот плагин подключается как обычный npm-пакет. Весь код плагина содержится в index.ts
файле и собирается обычной командой tsc
. В example
добавляем в tsconfig
следующие настройки:
{
"compilerOptions": {
"plugins": [
{
"name": "ts-plugin", // название workspace в packages/plugin/package.json
},
]
},
}
Самый простой код плагина может выглядеть так:
import type ts from 'typescript/lib/tsserverlibrary';
const plugin: ts.server.PluginModuleFactory = ({ typescript }) => ({
create: pluginCreateInfo =>
new Proxy(pluginCreateInfo.languageService, {
get: (target, property: keyof ts.LanguageService) => {
if (property === `getSemanticDiagnostics`) {
return (fileName: string) => {
const originalDiagnostic = target.getSemanticDiagnostics(fileName);
return originalDiagnostic.map(diagnostic => ({
...diagnostic,
messageText: `Привет, Хабр! ${diagnostic.messageText}`,
}));
};
}
return target[property];
},
}),
});
export = plugin;
По итогу получаем следующий результат:
Здесь и далее я использую Proxy для модификации оригинального languageService
. Однако можно воспользоваться и «классическим» копированием объекта. Оно описано в официальном гайде.
Разбор основных параметров
Разберём подробнее приведённый выше код, чтобы узнать, какие функции нам доступны:
ts.server.PluginModuleFactory
— функция, аргумент которой содержит текущий экземплярtypescript
. Он нам понадобится в дальнейшем. Возвращать функция должна объект со следующими ключами:getExternalFiles
— необязательная функция. Она возвращает список файлов, которые плагин хочет рассматривать в процессе работы. Они могут и не быть частью проекта TypeScript. Например, если мы пишем плагин дляvue
, все файлыvue
должны быть включены в проект;onConfigurationChanged
— необязательная функция. Вызывается, когда изменяется конфигурация плагина. Когда TypeScript сервер получает новую конфигурацию через файлtsconfig.json
, эта функция может реагировать на изменения настроек и применять их к работе плагина;create
— обязательная функция и самая важная для разработчика плагина. Её аргумент — объектPluginCreateInfo
. Его мы разберём позже. Функция должна вернутьLanguageService
— это основной объект, с которым мы будем работать. Он предоставляет основные инструменты для работы с кодом: автодополнение, навигацию по коду (go to definition), показ ошибок, показ типов и документацию;PluginCreateInfo
содержит следующие ключи:project
— предоставляет доступ к данным текущего проекта, включая список файлов, информацию о зависимостях и другие проектные метаданные. Плагин может использовать эту информацию, чтобы адаптироваться к структуре проекта и выполнять операции, требующие понимания проектной организации;languageService
— оригинальныйLanguageService
был описан выше;languageServiceHost
— предоставляет информацию, необходимую для работыlanguageService
. С его помощью плагин может запрашивать файлы, настройки компиляции, а также доступ к синтаксическим и семантическим данным проекта;serverHost
— предоставляет доступ к низкоуровневым операциям с файловой системой и проектом. Плагин может использовать его для чтения файлов, управления директориями и выполнения других операций с файловой системой, которые требуются для работы плагина;session
— это ключ, который предоставляет доступ к сессии сервера TypeScript. Сессия отслеживает текущее состояние работы редактора с проектом, управляет запросами между IDE и сервером TypeScript. Она позволяет плагину взаимодействовать с редактором и использовать такие функции, как регистрация команд или расширение возможностей обработки запросов;config
— содержит пользовательские настройки плагина, которые могут быть переданы черезtsconfig.json
. Эти параметры позволяют гибко настроить поведение плагина в зависимости от потребностей конкретного проекта.
Начинаем писать плагин для переопределения tsconfig.json
Из всего многообразия доступных параметров нам нужны: typescript
из аргумента функции плагина, languageService
, languageServiceHost
и config
из функции create
.
Основная наша задача — создать отдельные languageService
со своими настройками для каждой переопределённой папки.
Начнём с простого — определим настройки для нашего плагина. Это будет простой интерфейс:
import type ts from 'typescript/lib/tsserverlibrary';
export interface Override {
files: string[];
compilerOptions: ts.server.protocol.CompilerOptions;
}
export interface PluginConfig {
overrides: Override[];
}
ts.server.protocol.CompilerOptions
— это любые настройки, доступные в tsconfig.json
Передаём эти настройки в tsconfig.json
:
{
"compilerOptions": {
"strict": false,
"plugins": [
{
"name": "ts-overrides-plugin",
"overrides": [
{
"files": ["src/modern/**/*.{ts,tsx}"],
"compilerOptions": {
"strict": true,
},
},
]
},
]
},
}
Для определения путей я решил использовать glob
-паттерн. Для разработчиков он более привычен как по самому tsconfig
(поля files
, includes
, exclude
, etc), так и по eslint.config
.
Теперь мы можем получить эти настройки внутри плагина:
const plugin: ts.server.PluginModuleFactory = ({ typescript }) => ({
create: info => {
const { overrides } = info.config as PluginConfig;
console.log(overrides);
return info.languageService;
},
});
export = plugin;
Далее нам нужно создать отдельный languageService
для каждой папки, требующей переопределения настроек TypeScript. Создадим функцию getOverrideLanguageServices
и подробно её разберём:
import outmatch from 'outmatch';
const getOverrideLanguageServices = (
typescript: typeof ts,
overrides: Override[],
languageServiceHost: ts.LanguageServiceHost,
): ts.LanguageService[] =>
[...overrides].reverse().map(override => {
const overrideLanguageServiceHost: ts.LanguageServiceHost = {
fileExists: path => !!languageServiceHost.fileExists?.(path),
getCurrentDirectory: (): string => languageServiceHost.getCurrentDirectory(),
getDefaultLibFileName: (options: ts.CompilerOptions): string =>
languageServiceHost.getDefaultLibFileName(options),
getScriptSnapshot: fileName => languageServiceHost.getScriptSnapshot(fileName),
getScriptVersion: fileName => languageServiceHost.getScriptVersion(fileName),
readFile: (path, encoding) => languageServiceHost.readFile?.(path, encoding),
getCompilationSettings: () => ({
...languageServiceHost.getCompilationSettings(),
...typescript.convertCompilerOptionsFromJson(
override.compilerOptions,
languageServiceHost.getCurrentDirectory(),
).options,
}),
getScriptFileNames: () => {
const originalFiles = languageServiceHost.getScriptFileNames();
const isMatch = outmatch(override.files);
return originalFiles.filter(fileName =>
isMatch(relative(languageServiceHost.getCurrentDirectory(), fileName)),
);
},
};
return typescript.createLanguageService(overrideLanguageServiceHost);
});
Функция принимает параметры:
typescript
. Он нужен нам для создания экземпляраlanguageService
;overrides
— массив наших переопределений. Для каждого мы создадим отдельныйlanguageService
;languageServiceHost
. Он нужен для созданияlanguageService
. Как упоминалось выше, он позволит нам обращаться к файлам проекта и его конфигурации. С его помощью мы создадим собственныйlanguageServiceHost
, в котором переопределим функции для получения конфигурации и списка файлов проекта.
Для начала сделаем reverse
массива overrides
. Это нужно для того, чтобы при поиске через find
мы получали последний languageService
, способный обработать файл на наличие ошибок (по аналогии с eslint
).
Далее для каждого override
создадим languageServiceHost
, определив обязательные методы:
fileExists
должен вернуть при вызовеtrue
, если файл существует. Здесь, как и много где дальше, мы можем просто вызвать метод из оригинальногоlanguageServiceHost
. Стоит учесть и то, что вtypescript ≤ 4
этот метод был необязательным. Поэтому при вызове используемoptional chaining
;getCurrentDirectory
получает текущую директорию проекта (корневую папку). Используем оригинальный метод;getDefaultLibFileName
возвращает имя файла библиотеки по умолчанию (обычноlib.d.ts
). Используется для определения файла стандартной библиотеки TypeScript, который подключается к проекту. Используем оригинальный метод;getScriptSnapshot
возвращает снимок текущего состояния файла. Снимки используются TypeScript для анализа изменений в коде без необходимости перечитывать файл каждый раз. Используем оригинальный метод;getScriptVersion
возвращает текущую версию скрипта (файла), по которой TypeScript может определить, изменился ли файл. Эта строка позволяет TypeScript определять, изменился ли файл, и нужно ли его анализировать повторно;readFile
читает содержимое файла по указанному путиpath
с возможностью указать кодировку файла (encoding
). Как и в случае сfileExists
, здесь используется безопасный вызов с проверкой на наличие метода, так как вtypescript ≤ 4
этот метод был необязательным. Используем оригинальный метод;getCompilationSettings
возвращает объект настроек компиляции TypeScript (ts.CompilerOptions
). Этот метод объединяет настройки, которые предоставляет оригинальныйlanguageServiceHost
, с нашими настройками компиляции из объектаoverride.compilerOptions
. По сути, здесь происходит основная «магия» нашего плагина. Метод использует функциюconvertCompilerOptionsFromJson
, которая преобразует JSON-объект с настройками в валидные параметры компилятора TypeScript. Например, полеmodule
, которое можно задать разными способами (CommonJS
,commonjs
), он преобразует в число;getScriptFileNames
возвращает массив имён всех файлов скриптов в проекте. Сначала вызывается оригинальный методgetScriptFileNames
изlanguageServiceHost
, который возвращает список всех файлов, известных TypeScript. Затем с помощью функцииoutmatch
проверяется, какие из этих файлов соответствуют паттернам, указанным вoverride.files
. Фильтрация позволяет плагину работать только с файлами, которые соответствуют этим паттернам. Вместоoutmatch
можно было использовать любую другую библиотеку для работы сglob
-паттернами.
На основе созданного объекта overrideLanguageServiceHost
, мы создаём languageService
с помощью typescript.createLanguageService(overrideLanguageServiceHost)
. Далее именно его мы будем вызывать для файлов, в которых нужно переопределить настройки.
Определим функцию для получения languageService
по имени файла:
const getLanguageServiceForFile = (
fileName: string,
overrideLanguageServices: ts.LanguageService[],
originalLanguageService: ts.LanguageService,
): ts.LanguageService => {
const overrideServiceForFile = overrideLanguageServices.find(
override => override.getProgram()?.getRootFileNames().includes(fileName),
);
if (overrideServiceForFile) {
return overrideServiceForFile;
}
return originalLanguageService;
};
Здесь мы проходимся по массиву созданных выше languageService
и ищем тот, что готов проанализировать файл. Если не находим, берём оригинальный. На этом почти всё. Нам осталось только определить основную функцию плагина:
const plugin: ts.server.PluginModuleFactory = ({ typescript }) => ({
create: info => {
const { overrides } = info.config as IdePluginConfig;
const overrideLanguageServices = getOverrideLanguageServices(typescript, overrides, info.languageServiceHost);
return new Proxy(info.languageService, {
get: (target, property: keyof ts.LanguageService) => {
if (property === `getSemanticDiagnostics`) {
return (fileName: string) => {
const overrideForFile = getLanguageServiceForFile(fileName, overrideLanguageServices, target);
return overrideForFile.getSemanticDiagnostics(fileName);
};
}
return target[property];
},
});
},
});
export = plugin;
Здесь мы переопределяем оригинальный метод getSemanticDiagnostics
, который возвращает диагностику по файлу. В нём мы получаем переопределённый languageService
, созданный нами, и проводим с его помощью диагностику. По итогу в одном проекте мы видим разный вывод ошибок для разных папок:
Устраняем проблемы
Плагин выполняет свою основную задачу — с его помощью мы получаем разную диагностику для разных файлов. Он был написан на коленке, а потому с ним возникли некоторые проблемы. Разберём их подробнее.
Память
Взглянув на код, можно определить источник теоретических проблем — повышенное потребление памяти. Мы создаём несколько languageServiceHost
и languageService
, каждый из которых хранит состояние проекта: информацию о типах, содержимое файлов и т.д. На большом проекте и с большим количеством overrides
потребление памяти может сильно возрасти.
Как сгладить ситуацию? Например, с помощью typescript.createDocumentRegistry()
. Он возвращает DocumentRegistry
, который может хранить состояние файлов, их версии, выгружать неиспользуемые, а самое главное — шарить это состояние между разными экземплярами languageService
. К сожалению, мы не можем получить DocumentRegistry
из оригинальногоlanguageService
, поэтому создадим новый и будем использовать его для всех наших languageService
:
const getOverrideLanguageServices = (
typescript: typeof ts,
overridesFromConfig: Override[],
languageServiceHost: ts.LanguageServiceHost,
docRegistry: ts.DocumentRegistry,
): ts.LanguageService[] =>
[...overridesFromConfig].reverse().map(override => {
// ...
return typescript.createLanguageService(overrideLanguageServiceHost, docRegistry);
});
const plugin: ts.server.PluginModuleFactory = ({ typescript }) => ({
create: info => {
// ...
const docRegistry = typescript.createDocumentRegistry();
const overrideLanguageServices = getOverrideLanguageServices(
typescript,
overrides,
info.languageServiceHost,
docRegistry,
);
const originalLanguageServiceWithDocRegistry = typescript.createLanguageService(
info.languageServiceHost,
docRegistry,
);
//...
},
});
Таким образом, мы сильно оптимизируем работу с памятью.
Глобальные типы
При тестировании на реальном проекте вскрылась проблема — TypeScript
начал ругаться на отсутствие глобальных типов, описанных в d.ts
файлах. Ошибка была в создании LanguageServiceHost
, в формировании массива файлов:
getScriptFileNames: () => {
const originalFiles = project.getScriptFileNames();
const isMatch = outmatch(override.files);
return originalFiles.filter(fileName => isMatch(relative(project.getCurrentDirectory(), fileName)));
},
Здесь мы не учитываем d.ts
файлы, если они явно не указаны в override.files
. Проблема решается их явным добавлением в массив:
getScriptFileNames: () => {
const originalFiles = project.getScriptFileNames();
const isMatch = outmatch(override.files);
return originalFiles.filter(
fileName =>
fileName.endsWith(`.d.ts`) || isMatch(relative(project.getCurrentDirectory(), fileName)),
);
}
Подсветка типов
Мы переопределили только метод getSemanticDiagnostics
, который выводит ошибки. Но забыли про подсказки о типах. Например, создадим файл legacy.ts
, в котором strict
выключен:
Из-за отсутствия strictNullChecks
, итоговый тип переменной из string | undefined
превращается в string
. Импортируем эту переменную из другого файла modern.ts
, где strict
включён:
Ошибка отображается верно, но тип переменной всё ещё остаётся string
. Чтобы это исправить, переопределим ещё один метод в languageService
:
const plugin: ts.server.PluginModuleFactory = ({ typescript }) => ({
create: info => {
// ...
return new Proxy(originalLanguageServiceWithDocRegistry, {
get: (target, property: keyof ts.LanguageService) => {
if (property === `getQuickInfoAtPosition`) {
return ((fileName: string, position: number) => {
const overrideForFile = getLanguageServiceForFile(fileName, overrideLanguageServices, target);
return overrideForFile.getQuickInfoAtPosition(fileName, position);
});
}
// ...
return target[property as keyof ts.LanguageService];
},
});
},
});
Теперь и ошибка, и тип отображается корректно:
Дебаггинг
Поскольку плагин работает в IDE, обычный console.log или точка остановы не помогут нам в его отладке. Можно использовать 2 решения для отладки:
писать логи в файл средствами
nodejs
;использовать
info.project.projectService.logger.info
, запустить VSCode или WebStorm в режиме отладки и читать логи также из файла.
Совместимость с разными версиями TypeScript
Изначально я писал плагин только под версию TypeScript 5.3
и использовал Proxy
для создания не только languageService
, но и languageServiceHost
— всё работало хорошо. В обновлении TypeScript 5.4
разработчики изменили поведение languageServiceHost
и при использовании плагина любые ошибки типов перестали выводиться. Почитав исходники TypeScript
, я нашёл участок кода, который косвенно запрещает использование одного экземпляра languageServiceHost
несколькими languageService
. Это сделано с целью оптимизации. Поэтому я переписал Proxy на создание нового объекта с нуля.
Как было описано ранее, нужно быть осторожным с более старыми версиями TypeScript
— некоторые методы там отсутствовали или были опциональными.
В целом плагин, который вмешивается в стандартное поведение TypeScript
, достаточно шаткая история. Каждое изменение версии TypeScript
требует отладки.
Неправильное понимание Program
Изначально я пытался создать ts.Program
с помощью typescript.createProgram()
, передав в него переопределённые настройки, а не создавать новые languageService
. Далее в методе getSemanticDiagnostics
я пытался вызывать методы из созданной ts.Program
. Что-то вроде этого:
const plugin: ts.server.PluginModuleFactory = ({ typescript }) => ({
create: info => {
// ...
const newProgram = typescript.createProgram([`modern.ts`], {
...defaultOptions,
...overrideOptions,
});
return new Proxy(info.languageService, {
get: (target, property: keyof ts.LanguageService) => {
if (property === `getSemanticDiagnostics`) {
return ((fileName: string) => {
// ...
const sourceFile = newProgram.getSourceFile(fileName);
return newProgram.getSemanticDiagnostics(sourceFile);
});
}
return target[property];
},
});
},
});
Но этот способ переставал работать при первом же изменении файла, расположение диагностики уезжало:
Дело в том, что typescript.createProgram()
создаёт «слепок» всего проекта на момент создания. Он не предназначен для использования в реальном времени с постоянно меняющимися файлами. Можно бы было создавать новый ts.Program
при каждом вызове getSemanticDiagnostics
или при изменении файлов, но такой способ оказался очень ресурсозатратным и не нативным.
Бонус. Игнорирование файлов из проверки типов
Существуют редкие ситуации, когда нам нужно исключить файл из проверки типов. Например, это может быть JS-код, который нужно переписать на TypeScript
. Если файл один — это не проблема. Но если это целая папка, в некоторых файлах захочется временно отключить проверку типов.
TypeScript
позволяет заглушать ошибки для конкретных строк (// @ts-ignore
) и для всего файла (// ts-nocheck
). Но такие комментарии будут разбросаны по файлам — их легко потерять. С помощью плагина можно держать такие исключения в одном месте, в tsconfig.json
:
"compilerOptions": {
"plugins": [
{
"name": "ts-overrides-plugin",
"ignores": ["src/ignored/**/*.{ts,tsx}"]
]
}
Что касается реализации — к вышеупомянутому коду добавляется пара строк:
const { overrides, ignores } = info.config as IdePluginConfig;
const ignoresMatcher = ignores ? outmatch(ignores) : null;
// ...
if (property === `getSemanticDiagnostics`) {
return (fileName => {
if (ignoresMatcher?.(relative(info.project.getCurrentDirectory(), fileName))) {
return [];
}
const overrideForFile = getLanguageServiceForFile(fileName, overrideLanguageServices, target);
return overrideForFile.getSemanticDiagnostics(fileName);
}) as ts.LanguageService['getSemanticDiagnostics'];
}
Что же дальше?
А дальше мы запускаем команду tsc
и видим, что наш плагин не работает. И это ожидаемо — плагин предназначен только для IDE.
Мы уже можем отлавливать новые ошибки на этапе разработки, но на этапе транспиляции они всё ещё могут проскользнуть. Нас это не устраивает. А написание плагина для транспиляции — это совершенно другой мир со своими тонкостями и костылями. О нём я расскажу в следующей статье.
P.S. А с кодом плагина можно можно ознакомиться по ссылке.