esbuild icon indicating copy to clipboard operation
esbuild copied to clipboard

tsconfig `paths` are not transformed if not bundling

Open mhart opened this issue 4 years ago • 19 comments

$ cat tsconfig.json 
{
  "compilerOptions": {
    # ...
    "paths": {
      "lib/*": ["lib/*"]
    }
  },
}

$ echo "import logger from 'lib/logger'" | esbuild --format=cjs
# ...
const logger = __toModule(require("lib/logger"));

^ the import/require is left as-is (as opposed to being transformed to ./lib/logger), so will fail if run, say in Node.js

Was thinking about whether it's esbuild's responsibility to do the transform, and I feel it is: as a ts-to-js transformer, it should perform any transforms implied in tsconfig.json, including path transforms

mhart avatar Sep 18 '20 20:09 mhart

That said. It appears that ts-node has taken a similar stance, and also doesn't do the transform, which means ppl need to use https://github.com/dividab/tsconfig-paths – I guess that's the solution here? Would be interested to see if you think it's a feature that could be supported though

mhart avatar Sep 18 '20 21:09 mhart

How about module-alias? I'm going to use it on Koa apps.

Shyam-Chen avatar Feb 19 '21 10:02 Shyam-Chen

the behavior is not consistent with the tsc output which transforms aliases as expected

most common scenario for this requirement is when creating a lib which should not be published as bundle:

import { MyComponent } from '@/components';
$ cat tsconfig.json 
{
  "compilerOptions": {
    # ...
    "paths": {
      "lib/*": ["lib/*"]
    }
  },
}

$ echo "import logger from 'lib/logger'" | esbuild --format=cjs
# ...
const logger = __toModule(require("lib/logger"));

^ the import/require is left as-is (as opposed to being transformed to ./lib/logger), so will fail if run, say in Node.js

Was thinking about whether it's esbuild's responsibility to do the transform, and I feel it is: as a ts-to-js transformer, it should perform any transforms implied in tsconfig.json, including path transforms

g00fy- avatar Apr 20 '21 10:04 g00fy-

workaround is to use tsc-alias

g00fy- avatar Apr 21 '21 15:04 g00fy-

@g00fy- How does it work with esbuild?

Shyam-Chen avatar Apr 23 '21 03:04 Shyam-Chen

@evanw is there an official workaround to this?

jpike88 avatar Jul 31 '21 18:07 jpike88

Just create a simple loader, e.g.

const path = require('path');

/**
 * @see https://github.com/evanw/esbuild/issues/394
 */
module.exports = function (content) {
  const relativePath = path.relative(
    path.dirname(this.resourcePath),
    path.resolve(__dirname, '../src')
  );

  return content.replaceAll(
    `from "@/`,
    `from "${relativePath ? relativePath + '/' : './'}`
  );
};

and then use it to pre-process files:

{
  test: /\.(js|mjs|jsx|ts|tsx)$/,
  include: path.resolve(__dirname, 'src'),
  loader: path.resolve(
    __dirname,
    './esbuild-loaders/paths-loader.js'
  ),
}

gajus avatar Dec 06 '21 19:12 gajus

wrote https://www.npmjs.com/package/esbuild-ts-paths plugins mentioned above seemed overly complex/didn't work for me

frankleng avatar Jan 25 '22 11:01 frankleng

Maybe this is shortsighted of me, having a plugin handle this feels pretty heavy.

Below is not the most robust solution, feels like the right amount of effort.

// tsc-helpers.js
const path = require('path');
const tsconfig = require('../../tsconfig.json');

module.exports = {
    // Workaround for Typescript import paths not being transformed inside onResolve
    // @link https://github.com/evanw/esbuild/issues/394
    fixBaseUrl: (importPath) =>
        importPath.startsWith('./')
            ? importPath
            // Assumption: this is an aliased import
            // Read tsconfig directly to avoid any drift
            : path.join(tsconfig.compilerOptions.baseUrl, importPath),
};

// some-plugin.js
module.exports = {
    name: 'my-plugin',
    setup(build) {
        build.onResolve({ filter: ... }, async (args) => {
            args.path = tscHelper.fixBaseUrl(args.path);
            // 🍕 delicious
  ...

johnnybenson avatar Apr 08 '22 20:04 johnnybenson

the plugin is simple enough, and handles some of the edge cases I found + user requested. but yes rewriting path in esbuild is not difficult

frankleng avatar Apr 08 '22 22:04 frankleng

This seems to work: https://nodejs.org/docs/latest-v16.x/api/packages.html#subpath-exports

// tsconfig.json
...
"paths": {
  "#Src/*": [
    "./src/*"
  ]
},
...

// package.json
...
"imports": {
  "#Src/*": "./dist/*"
}
...

// ./src/index.ts
import { someFunction } from "#Src/util.js"; // <-- the source file name is `util.ts`, but import uses `.js`

someFunction(...);

Built using:

esbuild --format=esm --platform=node --target=node18 --outbase=src --outdir=dist --sourcemap ./src/*.ts

valeneiko avatar Oct 15 '22 22:10 valeneiko

wrote https://www.npmjs.com/package/esbuild-ts-paths plugins mentioned above seemed overly complex/didn't work for me

Works perfectly. Thanks. Looking forward to esbuild adopting this hint hint.

dougwithseismic avatar Jan 13 '23 11:01 dougwithseismic

Future ref for people looking for solutions that's in similar build scenario. I performed path alias transformations using tsc-alias

Conditions:

  1. Need to perform build without bundling, specifically for my scenario I just need to strip down TS type annotations and resolve paths aliasing to produce valid js for it to be run by regular node runtime
  2. Already type checked implementations with isolatedModules tsconfig option, as described in TS caveats to ensure non-breaking results
  3. Simple multi entrypoints setup pointing to all ts files

build.mjs setup

import { replaceTscAliasPaths } from 'tsc-alias';
import { build } from 'esbuild';
import fg from 'fast-glob';

await build({
  entryPoints: await fg(['src/**/*.ts']),
  outdir: 'dist',
  platform: 'node',
  tsconfig: 'tsconfig.json'
});

// Post build paths resolve since ESBuild only natively
// support paths resolution for bundling scenario
await replaceTscAliasPaths({
  configFile: 'tsconfig.json',
  watch: false,
  outDir: 'dist',
  declarationDir: 'dist'
});

If your runtime doesn't support top level await yet, you can change it using async/await IIFE

scripts entry is as simple as

package.json

{
  "scripts": {
    "build": "node build.mjs"
  }
}

Similar setup can be done only by using CLI instead of defining it programmatically, since tsc-alias ships with CLI, but you have to make sure your platform can correctly handle globstars or however you define your entrypoints list, personally I prefer this approach and just installing fast-glob to easily guarantee cross-platform behavior. Hope this helps

nadhifikbarw avatar May 06 '23 23:05 nadhifikbarw

is it also possible to use this code with tsup?

RedStar071 avatar May 30 '23 11:05 RedStar071

@RedStars071 generally yes, the core idea should be the same, assuming tsup doesn't automatically resolve path aliasing then by adjusting code snippet to their exposed build api it would follow the same logic.

nadhifikbarw avatar May 30 '23 12:05 nadhifikbarw

So 4 years later and its still no easier to fix path alias's at transpile time? Needing 17 extra build steps seems stupid, no?

maca134 avatar Apr 02 '24 17:04 maca134

It could be worse, at least there isn't a bot closing these for 30 days of inactivity like every other github project lol

colin969 avatar Apr 02 '24 22:04 colin969

It might be worth pointing out that if you use --bundle --packages=external then tsconfig paths are still applied as the per the docs:

  • external

This setting considers all import paths that "look like" package imports in the original source code to be package imports. Specifically import paths that don't start with a path segment of / or . or .. are considered to be package imports. The only two exceptions to this rule are subpath imports (which start with a # character) and TypeScript path remappings via paths and/or baseUrl in tsconfig.json (which are applied first).

Note that this is only the case with esbuild 0.19+, not with previous versions.

SystemParadox avatar Aug 06 '24 10:08 SystemParadox