В первой части статьи я описал процесс и причины написания плагина для 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. Это можно сдеать двумя способами:

  1. Live Compiler. Нужно просто заменить вызов yarn tsc на yarn tspc. Если используется webpack ts-loader или webpack fork-ts-checker, то передать в качестве compiler ts-patch/compiler. Работает только с TypeScript 5;

  2. 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 я решил поменять подход в самом плагине. Самое время вспомнить о разных способах трансформации и разобрать их:

  1. Source Transformer — если нам нужно трансформировать файлы, работая с AST-деревом. Этот способ даёт нам и ограниченную возможность модификации диагностики. Он используется по умолчанию.

  2. 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 постфактум :)

Комментарии (0)