
Привет, Хабр! Меня зовут Дима, я 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. А с кодом плагина можно можно ознакомиться по ссылке.
isumix
При понимании языка, Typescript не замедляет разработку. Напротив, приходит понимание что код получается более лаконичным и поддерживаемым.