Поддержка paths и baseUrl из tsconfig

Роль tsconfig.json в разрешении модулей

В экосистеме TypeScript файл tsconfig.json определяет не только параметры компиляции, но и правила резолва модулей. Среди наиболее значимых опций, влияющих на систему импорта:

  • baseUrl — базовый путь для относительного разрешения не-относительных импортов
  • paths — псевдонимы модулей (alias mapping)

Эти механизмы активно используются в крупных проектах для упрощения структуры импортов и сокращения длинных относительных путей вида:

import { Button } from '../. ./. ./. ./components/ui/button'

до более декларативного:

import { Button } from '@ui/button'

или:

import { Button } from 'components/ui/button'

Механика baseUrl

Опция baseUrl задаёт корневую директорию, относительно которой TypeScript интерпретирует неотносительные импорты.

Пример:

{
  "compilerOptions": {
    "baseUrl": "src"
  }
}

При такой конфигурации импорт:

import { api } from "services/api"

будет разрешён как:

src/services/api

Особенности поведения

  • работает только для неотносительных импортов
  • не влияет на пакеты из node_modules
  • является базой для вычисления paths

Механика paths

Опция paths расширяет систему резолва, позволяя задавать алиасы модулей через шаблоны.

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

{
  "compilerOptions": {
    "baseUrl": "src",
    "paths": {
      "@ui/*": ["components/ui/*"],
      "@lib/*": ["lib/*"],
      "@features/*": ["features/*"]
    }
  }
}

Принцип работы

При импорте:

import { Modal } from "@ui/modal"

TypeScript преобразует путь в:

src/components/ui/modal

Подстановочные шаблоны

Символ * работает как wildcard:

Шаблон Соответствие
@ui/* @ui/buttoncomponents/ui/button
@lib/* @lib/http/clientlib/http/client

Поведение Esbuild по умолчанию

Esbuild ориентирован на скорость и минимализм. В отличие от TypeScript Compiler API или Webpack, он:

  • не читает tsconfig.json для резолва путей автоматически
  • не применяет baseUrl и paths без дополнительной настройки

При стандартной сборке:

esbuild src/index.ts --bundle --outdir=dist

импорт:

import { Button } from "@ui/button"

приведёт к ошибке:

Could not resolve "@ui/button"

Поддержка через plugin: необходимость явного маппинга

Для корректной работы алиасов требуется использование плагина, который реализует трансформацию путей на этапе резолва.

На практике применяется подход:

  • чтение tsconfig.json
  • извлечение baseUrl и paths
  • преобразование алиасов в правила onResolve

Реализация поддержки paths через плагин

Базовая структура плагина выглядит следующим образом:

import fs from "fs"
import path from "path"

export const tsconfigPathsPlugin = (tsconfigPath = "./tsconfig.json") => {
  const config = JSON.parse(fs.readFileSync(tsconfigPath, "utf8"))
  const baseUrl = config.compilerOptions?.baseUrl || "."
  const paths = config.compilerOptions?.paths || {}

  const aliases = []

  for (const [key, values] of Object.entries(paths)) {
    const pattern = key.replace("/*", "")
    const targets = values.map(v => v.replace("/*", ""))

    aliases.push({
      pattern,
      targets
    })
  }

  return {
    name: "tsconfig-paths",
    setup(build) {
      for (const alias of aliases) {
        build.onResolve({ filter: new RegExp(`^${alias.pattern}`) }, args => {
          const matchedPath = args.path.replace(alias.pattern, "")

          for (const target of alias.targets) {
            const resolved = path.join(baseUrl, target, matchedPath)

            return {
              path: path.resolve(resolved)
            }
          }
        })
      }
    }
  }
}

Разбор ключевых этапов резолва

1. Чтение конфигурации

const config = JSON.parse(fs.readFileSync(tsconfigPath, "utf8"))

Извлекаются:

  • compilerOptions.baseUrl
  • compilerOptions.paths

2. Нормализация алиасов

Esbuild требует явной логики сопоставления, поэтому:

  • @ui/*@ui
  • ["components/ui/*"]components/ui

Wildcard удаляется, чтобы получить базовый префикс.


3. Регистрация onResolve

Esbuild использует систему хуков:

build.onResolve({ filter: /^@ui/ }, ...)

Этот хук перехватывает каждый импорт, начинающийся с @ui.


4. Построение физического пути

Ключевая логика:

const resolved = path.join(baseUrl, target, matchedPath)

Пример:

  • import: @ui/button
  • baseUrl: src
  • target: components/ui
  • result: src/components/ui/button

Поддержка нескольких targets

TypeScript позволяет задавать массив значений:

"paths": {
  "@lib/*": [
    "lib/*",
    "shared/lib/*"
  ]
}

В этом случае резолвер должен:

  • пробовать каждый target по порядку
  • возвращать первый существующий путь

Расширенная версия:

for (const target of alias.targets) {
  const resolved = path.resolve(baseUrl, target, matchedPath)

  if (fs.existsSync(resolved + ".ts") || fs.existsSync(resolved + ".js")) {
    return { path: resolved }
  }
}

Связь baseUrl и относительных путей

Важно учитывать, что baseUrl влияет только на non-relative imports.

Поведение:

Импорт Поведение
./utils обычный relative resolve
../utils обычный relative resolve
utils учитывает baseUrl
@alias/x обрабатывается через paths

Esbuild не применяет baseUrl напрямую, поэтому плагин обязан учитывать его вручную.


Частые проблемы при интеграции

Несовпадение структуры директорий

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

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

Но фактическая структура:

src/application/

Результат — резолвер указывает на несуществующие пути.


Конфликт с monorepo

В монорепозиториях часто встречаются:

  • несколько tsconfig.json
  • разные baseUrl
  • перекрывающиеся paths

Esbuild плагин должен учитывать контекст пакета, иначе резолв будет некорректным.


Отсутствие проверки существования файла

Базовый плагин может возвращать путь без проверки, что приводит к ошибкам позже в сборке. Более надёжный подход:

  • проверка .ts, .tsx, .js, .jsx
  • проверка index файлов

Разрешение index-модулей

TypeScript автоматически поддерживает:

import { x } from "@lib"

@lib/index.ts

В Esbuild это нужно реализовать явно:

const candidates = [
  resolved,
  path.join(resolved, "index.ts"),
  path.join(resolved, "index.js")
]

Поведение в bundle-режиме Esbuild

При --bundle Esbuild:

  • выполняет графовый обход модулей
  • применяет onResolve для каждого узла
  • кэширует результаты резолва

Это означает, что:

  • плагин с paths вызывается только при первом обращении
  • последующие импорты ускоряются за счёт кэша

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

Одно из преимуществ Esbuild — высокая скорость резолва. Однако плагины могут влиять на производительность:

Потенциальные узкие места:

  • fs.existsSync внутри onResolve
  • регулярные выражения с динамической генерацией
  • перебор множества paths

Оптимизация:

  • предварительная компиляция алиасов в Map
  • кэширование результатов резолва
  • минимизация файловых проверок

Альтернативные подходы

1. Предкомпиляция путей

Перед запуском Esbuild можно преобразовать tsconfig paths в:

  • alias map
  • JSON конфигурацию для резолвера

2. Использование готовых плагинов

Распространённые решения:

  • esbuild-plugin-tsconfig-paths
  • @esbuild-plugins/tsconfig-paths

Они уже реализуют:

  • поддержку wildcard
  • кеширование
  • fallback-логику

Интеграция в реальный проект

Типичный конфиг Esbuild с поддержкой paths:

import { build } from "esbuild"
import { tsconfigPathsPlugin } from "./plugins/tsconfig-paths.js"

build({
  entryPoints: ["src/index.ts"],
  bundle: true,
  outdir: "dist",
  platform: "node",
  plugins: [
    tsconfigPathsPlugin("./tsconfig.json")
  ]
})

Взаимодействие с TypeScript компилятором

Важно понимать различие:

  • TypeScript: проверяет типы и резолвит пути логически
  • Esbuild: выполняет физический резолв файлов

Поэтому возможна ситуация:

  • TypeScript компилирует без ошибок
  • Esbuild падает на unresolved module

Причина почти всегда в отсутствии синхронизации paths.


Особенности работы с ESM и CJS

При использовании разных модульных систем:

  • module: commonjs
  • module: esnext

путь резолва остаётся одинаковым, но:

  • расширения файлов могут влиять на итоговый результат
  • ESM требует более строгого соответствия путей

Итоговые принципы реализации поддержки tsconfig paths

  • baseUrl всегда применяется как корневая точка
  • paths требуют явного маппинга через onResolve
  • wildcard * должен преобразовываться в сегменты пути
  • порядок targets имеет значение
  • необходима проверка существования файлов
  • желательно кэширование результатов резолва
  • поведение Esbuild отличается от TypeScript и требует отдельной логики