[Feature]: handle ${configDir} TS template var in compilerOptions.paths
🚀 Feature Proposal
In TS v5.5, there is a new template var ${configDir} in compilerOptions.paths definition see official doc
Esbuild should handle it and replace this var with the correct path value.
Motivation
Keep being updated with TS standard
Example
The following project monorepo structure
monorepo/
├── tsconfig.base.json # Base TypeScript config
├── jest.config.base.ts # Base Jest config
└── packages/
├── <package_1>/
│ ├── package.json # Extends package-base.json
│ ├── tsconfig.json # Extends tsconfig.base.json
│ └── jest.config.ts # Extends jest.config.base.ts
└── <package_2>/
├── package.json # Extends package-base.json
├── tsconfig.json # Extends tsconfig.base.json
└── jest.config.ts # Extends jest.config.base.ts
with a defined tsconfig.base.json
{
"compilerOptions": {
"rootDir": "./",
"paths": {
"@monorepo/*": ["./packages/*"],
"@database/*": ["${configDir}/prisma/*"]
},
"include": [ "jest.config.base.ts" ]
}
would resolve @database as '^@database/(.*)$': '/path/to/monorepo/packages/package_1/prisma/$1' when loading from package_1/tsconfig.json.
I know it would be much more easier to just defined @database at tsconfig.json package level but unfortunately TS does not let extend the compilerOptions.paths property with the root one, it overwrites it...
As a workaround, if someone encounter this issue, here is a build script i'm using to replace ${configDir} using esbuild tsconfigRaw property with custom made tsconfig.json.
build.mjs
import esbuild from "esbuild";
import path from "node:path";
import { fileURLToPath } from "node:url";
import baseTsConfig from "../../../tsconfig.base.json" with { type: "json" };
import projectTsConfig from "../tsconfig.json" with { type: "json" };
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.resolve(__dirname, "..");
const MONOREPO_ROOT = path.resolve(PROJECT_ROOT, "../..");
// Process paths to use absolute paths
const processedPaths = Object.fromEntries(
Object.entries(baseTsConfig.compilerOptions.paths).map(([key, value]) => [
key,
value.map(configPath => {
// Handle ${configDir} replacement
if (configPath.includes("${configDir}")) return configPath.replace("${configDir}", PROJECT_ROOT);
// Handle @monorepo/* paths
if (key.startsWith("@monorepo/")) return path.resolve(MONOREPO_ROOT, configPath);
return configPath;
})
])
);
// Merge configurations
const mergedConfig = {
...baseTsConfig,
...projectTsConfig,
compilerOptions: {
...baseTsConfig.compilerOptions,
...projectTsConfig.compilerOptions,
paths: processedPaths,
baseUrl: MONOREPO_ROOT
},
extends: undefined // Remove the extends field as it's not needed in the final configuration
};
await esbuild.build({
entryPoints: [`${PROJECT_ROOT}/src/index.ts`],
bundle: true,
platform: "node",
target: "node23",
format: "esm",
outdir: `${PROJECT_ROOT}/dist`,
tsconfigRaw: JSON.stringify(mergedConfig),
resolveExtensions: [".ts", ".js", ".mjs", ".json"],
nodePaths: [MONOREPO_ROOT],
banner: {
js: `
import { createRequire } from 'module';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const require = createRequire(import.meta.url);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
`
}
});
In theory this should already be working as the ${configDir} feature was implemented last year (see #3782). Here's a live example of it working: link.
If it's not working for you, then please provide a way to reproduce the issue. The "monorepo structure" described in the initial post is not a reproduction because it's an incomplete code sample.
Closing due to lack of a reproduction.