Разрешение путей: tsconfig paths и tsconfig-paths-webpack-plugin

В крупных проектах относительные пути быстро становятся трудно поддерживаемыми:

import Button from '../. ./. ./. ./components/ui/Button';
import apiClient from '../. ./. ./shared/api/client';
import formatDate from '../. ./utils/date/formatDate';

При глубокой вложенности модулей возникают проблемы:

  • ухудшается читаемость кода;
  • сложнее перемещать файлы между директориями;
  • увеличивается вероятность ошибок в путях;
  • усложняется рефакторинг;
  • IDE начинает хуже отображать структуру проекта.

Для решения этой проблемы TypeScript поддерживает механизм алиасов путей через compilerOptions.paths в tsconfig.json.

Webpack, в свою очередь, не умеет автоматически читать эти настройки. Если настроить paths только в TypeScript, приложение может успешно компилироваться редактором и tsc, но падать во время сборки Webpack.

Именно для синхронизации TypeScript aliases и Webpack используется пакет tsconfig-paths-webpack-plugin.


Настройка paths в tsconfig.json

Базовая конфигурация

Простейший пример:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

Теперь вместо:

import Header from '../. ./. ./components/Header';

можно писать:

import Header from '@/components/Header';

Назначение baseUrl

Параметр baseUrl определяет базовую директорию, относительно которой работают aliases.

Пример:

{
  "compilerOptions": {
    "baseUrl": "."
  }
}

Точка означает корень проекта.

Если указать:

{
  "compilerOptions": {
    "baseUrl": "./src"
  }
}

то абсолютные импорты будут разрешаться относительно src.

Например:

import api from 'shared/api';

будет искать:

src/shared/api

Механизм paths

Синтаксис

{
  "paths": {
    "alias": ["path"],
    "alias/*": ["path/*"]
  }
}

Пример:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@components/*": ["src/components/*"],
      "@pages/*": ["src/pages/*"],
      "@shared/*": ["src/shared/*"]
    }
  }
}

Использование:

import Button from '@components/Button';
import HomePage from '@pages/HomePage';
import apiClient from '@shared/api/client';

Как TypeScript обрабатывает aliases

Важно понимать: TypeScript не переписывает import paths в результирующем JavaScript.

Например:

import Button from '@components/Button';

После компиляции путь останется:

import Button from '@components/Button';

TypeScript лишь проверяет существование модуля и корректность типов.

Разрешение алиасов во время выполнения должен обеспечивать:

  • Webpack;
  • Vite;
  • Node.js loader;
  • Babel;
  • Jest;
  • ts-node;
  • другая система сборки.

Проблема рассинхронизации TypeScript и Webpack

Частая ошибка:

{
  "compilerOptions": {
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

TypeScript:

import App from '@/App';

IDE не показывает ошибок.

Но Webpack выдаёт:

Module not found: Error: Can't resolve '@/App'

Причина:

  • TypeScript знает про aliases;
  • Webpack — нет.

Решение через resolve.alias

Без дополнительного плагина можно вручную продублировать aliases в Webpack:

const path = require('path');

module.exports = {
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src')
    }
  }
};

Недостатки такого подхода:

  • конфигурация дублируется;
  • aliases нужно обновлять в двух местах;
  • возникает риск рассинхронизации;
  • сложнее поддерживать monorepo;
  • сложнее масштабировать проект.

tsconfig-paths-webpack-plugin

Пакет автоматически читает:

  • baseUrl;
  • paths;
  • расширения;
  • tsconfig-файл.

И передаёт эти настройки в систему module resolution Webpack.


Установка

npm install tsconfig-paths-webpack-plugin --save-dev

или:

yarn add tsconfig-paths-webpack-plugin -D

Базовое подключение

tsconfig.json

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@components/*": ["src/components/*"],
      "@shared/*": ["src/shared/*"]
    }
  }
}

webpack.config.js

const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');

module.exports = {
  resolve: {
    plugins: [
      new TsconfigPathsPlugin()
    ]
  }
};

После этого Webpack начинает понимать aliases из TypeScript.


Как работает плагин

Плагин:

  1. Находит tsconfig.json.

  2. Читает compilerOptions.

  3. Извлекает:

    • baseUrl;
    • paths.
  4. Интегрируется в resolver Webpack.

  5. Перехватывает module resolution.

  6. Подменяет alias-пути на реальные файловые пути.


Полный пример конфигурации

Структура проекта

project/
├── src/
│   ├── components/
│   ├── pages/
│   ├── shared/
│   └── app/
├── tsconfig.json
└── webpack.config.js

tsconfig.json

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@app/*": ["src/app/*"],
      "@pages/*": ["src/pages/*"],
      "@components/*": ["src/components/*"],
      "@shared/*": ["src/shared/*"]
    }
  }
}

webpack.config.js

const path = require('path');
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');

module.exports = {
  entry: './src/index.ts',

  resolve: {
    extensions: ['.ts', '.tsx', '.js'],

    plugins: [
      new TsconfigPathsPlugin({
        configFile: path.resolve(__dirname, 'tsconfig.json')
      })
    ]
  },

  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: 'ts-loader'
      }
    ]
  }
};

Использование aliases

Импорт компонентов

import Button from '@components/Button';
import Modal from '@components/Modal';

Импорт shared-модулей

import apiClient from '@shared/api/client';
import formatDate from '@shared/utils/date';

Импорт страниц

import HomePage from '@pages/HomePage';
import ProfilePage from '@pages/ProfilePage';

Поддержка wildcard aliases

Конфигурация

{
  "paths": {
    "@services/*": ["src/services/*"]
  }
}

Использование

import authService from '@services/auth';
import userService from '@services/user';

Wildcard * заменяется соответствующей частью пути.


Алиасы без wildcard

Допустим:

{
  "paths": {
    "@config": ["src/config/index.ts"]
  }
}

Использование:

import config from '@config';

Это удобно для:

  • конфигураций;
  • singleton-модулей;
  • глобальных сервисов;
  • constants;
  • env wrappers.

Указание конкретного tsconfig

По умолчанию плагин ищет:

tsconfig.json

Но можно указать файл явно:

new TsconfigPathsPlugin({
  configFile: './configs/tsconfig.frontend.json'
})

Monorepo и несколько tsconfig

В monorepo часто используются:

packages/
apps/
shared/

И несколько tsconfig:

tsconfig.base.json
tsconfig.client.json
tsconfig.server.json

Пример:

new TsconfigPathsPlugin({
  configFile: './tsconfig.client.json'
})

Совместимость с ts-loader

Наиболее распространённая связка:

TypeScript
+ ts-loader
+ tsconfig-paths-webpack-plugin

Пример:

module.exports = {
  resolve: {
    extensions: ['.ts', '.tsx', '.js'],
    plugins: [
      new TsconfigPathsPlugin()
    ]
  },

  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: 'ts-loader'
      }
    ]
  }
};

Совместимость с babel-loader

Плагин работает независимо от transpiler.

Даже если TypeScript обрабатывается через Babel:

{
  test: /\.tsx?$/,
  use: {
    loader: 'babel-loader'
  }
}

aliases продолжат работать.


Совместимость с fork-ts-checker-webpack-plugin

Типичная production-конфигурация:

babel-loader
+ fork-ts-checker-webpack-plugin
+ tsconfig-paths-webpack-plugin

Поскольку проверка типов выполняется отдельно, aliases остаются централизованными в tsconfig.


Взаимодействие с resolve.extensions

Плагин учитывает resolve.extensions.

Пример:

resolve: {
  extensions: ['.ts', '.tsx', '.js'],
  plugins: [
    new TsconfigPathsPlugin()
  ]
}

Webpack сможет находить:

Button.ts
Button.tsx
Button.js

без указания расширения.


Настройка extensions внутри плагина

Иногда требуется явно передать extensions:

new TsconfigPathsPlugin({
  extensions: ['.ts', '.tsx', '.js']
})

Это особенно важно при нестандартной конфигурации resolver.


Настройка mainFields

Плагин интегрируется в стандартный механизм resolution Webpack.

Например:

resolve: {
  mainFields: ['browser', 'module', 'main']
}

Aliases продолжают корректно работать.


baseUrl без paths

Допустим:

{
  "compilerOptions": {
    "baseUrl": "./src"
  }
}

Теперь можно писать:

import api from 'shared/api';
import Button from 'components/Button';

без ../. ./. ./.

Плагин также поддерживает этот сценарий.


Пример enterprise-структуры

tsconfig.json

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@app/*": ["src/app/*"],
      "@processes/*": ["src/processes/*"],
      "@pages/*": ["src/pages/*"],
      "@widgets/*": ["src/widgets/*"],
      "@features/*": ["src/features/*"],
      "@entities/*": ["src/entities/*"],
      "@shared/*": ["src/shared/*"]
    }
  }
}

Импорты

import Header from '@widgets/Header';
import LoginForm from '@features/auth/LoginForm';
import UserCard from '@entities/user/UserCard';

Такой подход широко применяется в архитектурах:

  • Feature-Sliced Design;
  • Domain Driven Design;
  • modular monolith;
  • enterprise frontend.

Производительность

Плагин практически не влияет на скорость сборки.

Основные затраты:

  • чтение tsconfig;
  • построение internal mapping.

Обычно это выполняется один раз при старте сборки.


Типичные ошибки

Плагин подключён не в resolve.plugins

Неправильно:

plugins: [
  new TsconfigPathsPlugin()
]

Правильно:

resolve: {
  plugins: [
    new TsconfigPathsPlugin()
  ]
}

Отсутствует baseUrl

Ошибка:

{
  "compilerOptions": {
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

Без baseUrl aliases работать не будут.

Правильно:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

Несовпадение aliases

Ошибка:

{
  "@components/*": ["src/components/*"]
}

Импорт:

import Button from '@/components/Button';

Aliases должны совпадать.


Конфликт с resolve.alias

Если одновременно используются:

resolve.alias

и:

TsconfigPathsPlugin

возможны конфликты resolution priority.

Например:

resolve: {
  alias: {
    '@': path.resolve(__dirname, 'legacy')
  },

  plugins: [
    new TsconfigPathsPlugin()
  ]
}

Поведение становится неоднозначным.

Лучше использовать единый источник истины.


Проблемы Jest

Webpack aliases не влияют на Jest.

Для тестов требуется отдельная настройка:

moduleNameMapper: {
  '^@/(.*)$': '<rootDir>/src/$1'
}

или использование:

ts-jest
pathsToModuleNameMapper

Проблемы Node.js runtime

Node.js не понимает TypeScript aliases автоматически.

Например:

node dist/index.js

может завершиться ошибкой:

Cannot find module '@shared/utils'

Возможные решения:

  • ts-node + tsconfig-paths/register;
  • bundling через Webpack;
  • Babel module resolver;
  • Node.js imports maps;
  • runtime aliases.

Использование с ts-node

Установка

npm install tsconfig-paths --save-dev

Запуск

ts-node -r tsconfig-paths/register src/index.ts

Теперь aliases работают и вне Webpack.


Отличие tsconfig-paths и tsconfig-paths-webpack-plugin

tsconfig-paths

Используется:

  • в Node.js runtime;
  • ts-node;
  • server-side scripts.

tsconfig-paths-webpack-plugin

Используется:

  • только внутри Webpack resolver.

Порядок resolution

Webpack выполняет:

  1. Проверку aliases.
  2. Проверку plugins resolver.
  3. Поиск файлов.
  4. Проверку extensions.
  5. Поиск package.json.
  6. Анализ main/module/browser.

Плагин встраивается именно в этап resolver plugins.


Debugging resolution

Для диагностики можно включить logging:

infrastructureLogging: {
  level: 'verbose'
}

или:

webpack --profile --progress

Пример production-конфигурации

const path = require('path');
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');

module.exports = {
  mode: 'production',

  entry: './src/index.tsx',

  resolve: {
    extensions: ['.tsx', '.ts', '.js'],

    plugins: [
      new TsconfigPathsPlugin({
        configFile: path.resolve(__dirname, 'tsconfig.json'),
        extensions: ['.tsx', '.ts', '.js']
      })
    ]
  },

  module: {
    rules: [
      {
        test: /\.tsx?$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader'
        }
      }
    ]
  }
};

Преимущества централизованных aliases

Единый источник конфигурации

Все aliases находятся в одном месте:

tsconfig.json

Упрощение рефакторинга

Перемещение директорий не требует массового переписывания относительных импортов.


Улучшение читаемости

Вместо:

../. ./. ./. ./. ./shared/lib/date

используется:

@shared/lib/date

Масштабируемость

Подход особенно полезен:

  • в monorepo;
  • enterprise frontend;
  • больших React-приложениях;
  • multi-package архитектурах;
  • modular frontend systems.

Практические рекомендации

Использование коротких aliases

Хорошо:

@shared
@entities
@features

Плохо:

@very-long-shared-folder-name

Не использовать слишком много aliases

Избыточное количество aliases усложняет навигацию.

Обычно достаточно:

  • @;
  • @shared;
  • @components;
  • @pages;
  • @features.

Не смешивать относительные и абсолютные импорты хаотично

Лучше придерживаться правил:

  • локальные импорты — относительные;
  • межмодульные — абсолютные aliases.

Пример:

import Button from './Button';
import Modal from '../Modal';

import api from '@shared/api';
import UserCard from '@entities/user';

Использование aliases в больших командах

Aliases становятся частью архитектурных соглашений проекта.

Например:

@shared     → общие модули
@entities   → бизнес-сущности
@features   → пользовательские сценарии
@widgets    → композиционные блоки
@pages      → страницы

Это формирует предсказуемую структуру импортов и улучшает поддержку кода в долгосрочной перспективе.