Node 原生运行 TypeScript,为什么 tsconfig paths 没生效?
先确认谁在读配置,再用六次隔离运行分清路径映射、目标文件和类型检查。
- 先记录真实启动命令与 Node 版本,分清哪一个工具读取 paths
- 单包原生 .ts 示例使用 package.json imports 的 #math 映射
- 映射缺失与目标缺失分别排查,声明路径不会生成 dist 文件
- 退出 0 不能替代类型检查;保留错误类型参数这个负控
1. 先复现:paths 已经写好,node 仍然找不到模块
你给一个小脚本写了 tsconfig.json,把 @app/math 指向 src/math.ts。文件确实存在,但直接执行 node src/main.ts,终端仍然报告 ERR_MODULE_NOT_FOUND。先别改编辑器设置,也别把多个加载器一起装上;可以用四个文件确认:这个启动命令有没有读取你改的那份配置。
本例限定单个 package.json 包、ESM、Node 原生执行 .ts 源码。我们用内置类型剥离运行一个带 number 标注的加法函数,先保留失败,再改为 package.json 的 imports 映射,最后测试映射缺失、目标文件缺失和错误参数类型。没有编译产物、框架、monorepo 或第三方加载器。
实测:2026-10-13,Linux x86_64、Node v24.19.0;6 个合成运行场景、12 项测试通过。示例只用 Node 标准库,不安装包、不访问网络、不调用模型,也不改读者项目。本文没有运行 tsc、编辑器或真实项目迁移;结果不推广到所有 Node 版本。
2. 把失败缩成一个临时单包
下面是 paths-only 场景的完整四文件输入。可以在一个新建的空目录手工保存,或者使用后文的 fixtures.mjs 自动生成到系统临时目录。不要覆盖已有项目的 package.json。四个文件的相对位置是:
package.json
tsconfig.json
src/main.ts
src/math.tspackage.json 显式声明 ESM:
{
"private": true,
"type": "module"
}
tsconfig.json 只保留这次要调查的配置:
{
"compilerOptions": {
"paths": {
"@app/math": ["./src/math.ts"]
}
}
}
src/math.ts:
export function add(a: number, b: number): number {
return a + b;
}
src/main.ts:
import { add } from '@app/math';
console.log(JSON.stringify({ sum: add(2, 3) }));
从这个新目录执行以下命令。这里是 macOS/Linux 的 shell 写法;清除变量只影响这条命令。Windows 可在一个没有自定义 NODE_OPTIONS、NODE_PATH 的终端运行相同 node 命令。
node --version
env -u NODE_OPTIONS -u NODE_PATH node src/main.ts本次 Node 版本为 v24.19.0。程序退出码为 1,stdout 为空,stderr 包含下面的稳定信息;实际堆栈还会带临时绝对路径和 Node 内部行号,不适合整段作为跨机器断言。
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@app/math' imported from .../src/main.ts
code: 'ERR_MODULE_NOT_FOUND'3. 配置的读取者决定它能解决什么
Node v24.19.0 的类型剥离文档说明,这条轻量运行路径忽略 tsconfig.json,也不做类型检查。于是本例里只改 paths,不会给 Node 增加 @app/math 映射。TypeScript 的 paths 说明还指出,paths 不会改写 tsc 输出中的导入路径;它描述的是 TypeScript 应怎样查找模块,运行或打包工具仍须理解同一映射。
因此,编辑器里能跳转、静态检查能找到文件、运行时能加载模块,是三个需要分别确认的结果。本例没有运行编辑器或 TypeScript 编译器,不能替前两项宣告通过;这里只观察最后一项。
文档中的术语也要分开:TypeScript features 小节的 “import aliases” 涉及 import = 一类 TypeScript 语法;本文处理的是模块说明符 @app/math 与 tsconfig paths 的映射。不要看到同一个 alias 单词就把两种限制混在一起。版本化语法说明
4. 修复这个原生执行例子:给 #math 声明包内映射
将 package.json 改成下面的完整内容,并把 src/main.ts 的导入改成 #math。src/math.ts 不变;原 tsconfig.json 可以暂时保留,便于下一步对照。
{
"private": true,
"type": "module",
"imports": {
"#math": "./src/math.ts"
}
}
import { add } from '#math';
console.log(JSON.stringify({ sum: add(2, 3) }));
env -u NODE_OPTIONS -u NODE_PATH node src/main.ts
# stdout: {"sum":5}
# exit: 0Node 的 subpath imports是定义在 package.json 中的包内映射,键以 # 开头。本例的目标 ./src/math.ts 相对该包解析,包含真实文件扩展名。它没有把 @app/math 自动改名;必须同时改源码的模块说明符和 package.json 映射。
这里只用显式的 #math 键,避免把通配符、条件映射和多包边界混进第一步。也不要照抄旧文章里“#/ 一律非法”的判断:这份 v24.19.0 文档记载从 v24.14.0 起允许 #/ 开头的子路径导入;本文不需要依赖它。
如果你不需要包内短名称,也可以直接写相对路径 ./math.ts;关键是让实际运行时有明确的解析路径。真实项目是否保留 paths,要按它已有的编译器和工具链需求决定,不能因本例成功就一并删掉。
5. 逐项改坏:确认成功到底依赖哪一项
后文自动脚本每次重建四文件包,再启动新的 Node 进程,避免同一进程的模块缓存或上一步改动污染结果。六组输入的差异及实测输出如下。错误行的 stdout 均为空;成功行的 stderr 均为空,stdout 末尾均有换行。
| 场景 | 相对成功例的变化 | 退出码 / stdout / 错误码 |
|---|---|---|
| paths-only | 导入 @app/math;只有 paths,没有 imports | 1;空;ERR_MODULE_NOT_FOUND |
| package-imports | 导入 #math;imports 指向 ./src/math.ts | 0;{"sum":5};无 |
| wrong-tsconfig-path | imports 不变;paths 的 #math 指向 ./does-not-exist.ts | 0;{"sum":5};无 |
| missing-imports-map | 导入 #math;删除 imports;保留 paths 的正确目标 | 1;空;ERR_PACKAGE_IMPORT_NOT_DEFINED |
| missing-dist-file | imports 的 #math 指向不存在的 ./dist/math.js | 1;空;ERR_MODULE_NOT_FOUND |
| wrong-argument-type | 恢复成功映射;调用 add("2", 3) | 0;{"sum":"23"};无 |
第三项最能排除“恰巧读到了 paths”的猜测:我们把它的 #math 目标改坏,程序仍通过。第四项反过来删除 Node 映射,即使 paths 给出正确目标也救不回来。第五项中映射已经存在,但它指向的 dist/math.js 根本没有生成;声明映射不会替你编译文件。
6. 最后一个成功,暴露了类型检查的边界
src/math.ts 的参数和返回值都写了 number,但第六项把第一个参数换成字符串 "2",实际仍退出 0,得到 {"sum":"23"}。类型标注被移除后,函数体还是 a + b;JavaScript 于是执行字符串拼接。这个合成反例说明,启动成功不能充当静态类型检查的通过记录。
接到真实项目时,保留项目已有的类型检查步骤,以及外部输入需要的运行时校验。这里没有安装 TypeScript,也没有运行 tsc,因此不提供一份“类型检查已通过”的结果。若你的工程还要编译、打包或发布 JavaScript,源码路径和产物路径必须另作验收;不能直接沿用本例只指向 .ts 的映射。
7. 文件一 fixtures.mjs:固定输入,不在生成器里写答案
建立一个新的实验目录,将后面的四份 .mjs 文件按名称放在同一层。fixtures.mjs 完整生成刚才的六组四文件输入;first 是写入源码的字面量,来自本文固定数据,不接收用户输入。它只提供输入,不计算预期结果。
// Inputs only. Expected outputs belong to test.mjs, not this fixture generator.
const math = `export function add(a: number, b: number): number {
return a + b;
}
`;
const main = (specifier, first = '2') =>
`import { add } from '${specifier}';\n` +
`console.log(JSON.stringify({ sum: add(${first}, 3) }));\n`;
const json = (value) => JSON.stringify(value, null, 2) + '\n';
function files(specifier, imports, paths, first = '2') {
const pkg = { private: true, type: 'module' };
if (imports !== undefined) pkg.imports = imports;
return {
'package.json': json(pkg),
'tsconfig.json': json({ compilerOptions: { paths } }),
'src/math.ts': math,
'src/main.ts': main(specifier, first),
};
}
const nativeMap = { '#math': './src/math.ts' };
export const fixtures = [
{ name: 'paths-only', files: files('@app/math', undefined,
{ '@app/math': ['./src/math.ts'] }) },
{ name: 'package-imports', files: files('#math', nativeMap,
{ '@app/math': ['./src/math.ts'] }) },
{ name: 'wrong-tsconfig-path', files: files('#math', nativeMap,
{ '#math': ['./does-not-exist.ts'] }) },
{ name: 'missing-imports-map', files: files('#math', undefined,
{ '#math': ['./src/math.ts'] }) },
{ name: 'missing-dist-file', files: files('#math',
{ '#math': './dist/math.js' }, { '#math': ['./src/math.ts'] }) },
{ name: 'wrong-argument-type', files: files('#math', nativeMap,
{ '#math': ['./src/math.ts'] }, '"2"') },
];
8. 文件二 harness.mjs:每次新进程,结束清理临时目录
withFixture 只写自己用 mkdtemp 创建的系统临时目录,finally 负责正常或异常收尾。--keep 使用同一个创建过程,只跳过删除,让读者能逐个打开输入文件。该写入器仅处理上文固定可信文件名,不适合作为任意外部 JSON 或压缩包的解包工具。
子进程使用当前 Node 可执行文件,不通过 npm 或 npx。cleanEnv 清除 NODE_OPTIONS 和 NODE_PATH,防止子进程被额外加载器或路径变量改变;它不修改调用者环境。这个处理发生在脚本启动之后,所以运行实验脚本本身也应使用干净环境。每个进程还有 10 秒上限,发生启动错误或超时就抛错,不把它伪装成案例通过。
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { spawnSync } from 'node:child_process';
export function cleanEnv(source = process.env) {
return Object.fromEntries(Object.entries(source).filter(([key]) =>
!['NODE_OPTIONS', 'NODE_PATH'].includes(key.toUpperCase())));
}
// Only the fixed, trusted fixtures in fixtures.mjs are accepted as inputs here.
// This helper is not a general-purpose untrusted archive/file writer.
export function withFixture(fixture, action, { keep = false } = {}) {
const root = mkdtempSync(join(tmpdir(), 'native-ts-imports-'));
try {
for (const [relative, contents] of Object.entries(fixture.files)) {
const target = join(root, relative);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, contents, 'utf8');
}
return action(root);
} finally {
if (!keep) rmSync(root, { recursive: true, force: true });
}
}
export function runFixture(fixture, { keep = false, foreignCwd = false,
env = process.env } = {}) {
return withFixture(fixture, (root) => {
const entry = join(root, 'src', 'main.ts');
const child = spawnSync(process.execPath,
[foreignCwd ? entry : './src/main.ts'], {
cwd: foreignCwd ? tmpdir() : root,
env: cleanEnv(env), encoding: 'utf8', timeout: 10_000,
maxBuffer: 1024 * 1024,
});
if (child.error) throw child.error;
return {
name: fixture.name, root, node: process.version,
status: child.status, signal: child.signal,
stdout: child.stdout, stderr: child.stderr,
errorCode: child.stderr.match(/code: ['"](ERR_[A-Z_]+)['"]/)?.[1] ?? null,
};
}, { keep });
}
9. 文件三 demo.mjs:查看完整摘要,或保留原始文件
import { fixtures } from './fixtures.mjs';
import { runFixture } from './harness.mjs';
const args = process.argv.slice(2);
if (args.some((arg) => arg !== '--keep')) {
throw new Error('Usage: node demo.mjs [--keep]');
}
const keep = args.includes('--keep');
const results = fixtures.map((fixture) => {
const result = runFixture(fixture, { keep });
if (keep) console.error(`${fixture.name}: ${result.root}`);
return {
name: result.name, status: result.status, signal: result.signal,
stdout: result.stdout, errorCode: result.errorCode,
};
});
console.log(JSON.stringify({ node: process.version, kept: keep, results }, null, 2));
在这四份文件所在目录运行:
env -u NODE_OPTIONS -u NODE_PATH node demo.mjs
env -u NODE_OPTIONS -u NODE_PATH node --test test.mjsdemo 的实际完整 stdout 如下。它压缩显示错误码,原始 stderr 仍由 harness 返回给测试核对。kept=false 表示默认执行后清理临时目录。
{
"node": "v24.19.0",
"kept": false,
"results": [
{
"name": "paths-only",
"status": 1,
"signal": null,
"stdout": "",
"errorCode": "ERR_MODULE_NOT_FOUND"
},
{
"name": "package-imports",
"status": 0,
"signal": null,
"stdout": "{\"sum\":5}\n",
"errorCode": null
},
{
"name": "wrong-tsconfig-path",
"status": 0,
"signal": null,
"stdout": "{\"sum\":5}\n",
"errorCode": null
},
{
"name": "missing-imports-map",
"status": 1,
"signal": null,
"stdout": "",
"errorCode": "ERR_PACKAGE_IMPORT_NOT_DEFINED"
},
{
"name": "missing-dist-file",
"status": 1,
"signal": null,
"stdout": "",
"errorCode": "ERR_MODULE_NOT_FOUND"
},
{
"name": "wrong-argument-type",
"status": 0,
"signal": null,
"stdout": "{\"sum\":\"23\"}\n",
"errorCode": null
}
]
}
想看某个失败场景的完整输入和堆栈,可以运行保留模式。它会把六个新目录的绝对位置逐行写到 stderr;随后进入其中一个目录,再执行 node src/main.ts。检查完后手工删除本次列出的六个目录即可,保留模式不会自动清理。
env -u NODE_OPTIONS -u NODE_PATH node demo.mjs --keep这能直接回答“我实际运行的是哪个 package.json、哪个 main.ts、有没有 dist/math.js”。默认测试和演示不在当前目录留下项目文件;测试里的保留场景也会在它自己的 finally 中删除。
10. 文件四 test.mjs:写死预期,再核对输出与收尾
expected 是独立写下的六条判定,不从当前程序输出来生成。前三个错误场景还核对 stderr 中的目标信息,避免把另一种启动故障算成预期失败。测试没有比较临时根目录、完整堆栈或耗时。
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { fixtures } from './fixtures.mjs';
import { cleanEnv, runFixture, withFixture } from './harness.mjs';
// Fixed expectations: these are not calculated from fixture inputs or outputs.
const expected = [
['paths-only', 1, '', 'ERR_MODULE_NOT_FOUND', "Cannot find package '@app/math'"],
['package-imports', 0, '{"sum":5}\n', null, null],
['wrong-tsconfig-path', 0, '{"sum":5}\n', null, null],
['missing-imports-map', 1, '', 'ERR_PACKAGE_IMPORT_NOT_DEFINED',
'Package import specifier "#math" is not defined'],
['missing-dist-file', 1, '', 'ERR_MODULE_NOT_FOUND', '/dist/math.js'],
['wrong-argument-type', 0, '{"sum":"23"}\n', null, null],
];
for (const [name, status, stdout, errorCode, detail] of expected) {
test(name, () => {
const fixture = fixtures.find((item) => item.name === name);
assert.ok(fixture, `missing input: ${name}`);
const actual = runFixture(fixture);
assert.equal(actual.status, status);
assert.equal(actual.signal, null);
assert.equal(actual.stdout, stdout);
assert.equal(actual.errorCode, errorCode);
if (detail) assert.ok(actual.stderr.includes(detail), actual.stderr);
else assert.equal(actual.stderr, '');
assert.equal(existsSync(actual.root), false, 'temporary root was removed');
});
}
test('only the six declared cases are present', () => {
assert.deepEqual(fixtures.map((item) => item.name), expected.map(([name]) => name));
});
test('remove loader-related variables without mutating the caller', () => {
const source = { NODE_OPTIONS: '--no-strip-types', NODE_PATH: '/unused',
node_options: '--bad-flag', KEEP_ME: 'yes' };
assert.deepEqual(cleanEnv(source), { KEEP_ME: 'yes' });
assert.equal(source.NODE_OPTIONS, '--no-strip-types');
});
test('a supplied hostile child environment cannot inject a loader', () => {
const result = runFixture(fixtures[1], { env: {
...process.env, NODE_OPTIONS: '--import=file:///missing-loader.mjs',
NODE_PATH: '/missing-node-path',
} });
assert.equal(result.status, 0);
assert.equal(result.stdout, '{"sum":5}\n');
assert.equal(result.stderr, '');
});
test('cleanup still runs when the callback throws', () => {
let root;
assert.throws(() => withFixture(fixtures[1], (created) => {
root = created;
throw new Error('deliberate callback failure');
}), /deliberate callback failure/);
assert.equal(existsSync(root), false);
});
test('keep preserves a complete readable fixture for manual inspection', () => {
let result;
try {
result = runFixture(fixtures[1], { keep: true });
assert.equal(result.status, 0);
for (const [relative, contents] of Object.entries(fixtures[1].files)) {
assert.equal(readFileSync(join(result.root, relative), 'utf8'), contents);
}
} finally {
if (result) rmSync(result.root, { recursive: true, force: true });
}
});
test('absolute entry resolves imports from its package with a different cwd', () => {
const result = runFixture(fixtures[1], { foreignCwd: true });
assert.equal(result.status, 0);
assert.equal(result.stdout, '{"sum":5}\n');
assert.equal(result.stderr, '');
});
本次运行结果为 tests 12、pass 12、fail 0、cancelled 0、skipped 0、todo 0。六项覆盖表中的输入输出;另六项检查场景集合、环境过滤、加载器注入隔离、异常清理、保留文件内容,以及从其他 cwd 启动绝对入口时的包内解析。正文复制重建和本地源码 ZIP 解压后也分别运行通过。
如果第一个成功例失败,先核对 node --version、四文件位置、type=module、导入是否已改为 #math,以及 imports 是否准确指向 src/math.ts。不要先改 expected 来迎合当前输出。若你有意改了范围,例如加入加载器或运行编译产物,就应为那条新执行路径单独记录输入和预期。
11. 回到项目时,按错误发生的位置查
- ERR_PACKAGE_IMPORT_NOT_DEFINED:先看错误里的 # 名称,再检查包含该导入文件的包有没有相应 imports 项。本例是完全删除映射造成的;其他包、其他条件分支要另查。
- ERR_MODULE_NOT_FOUND:先看错误究竟说找不到包,还是找不到目标文件。本例的 @app/math 是查包失败;./dist/math.js 是映射后的文件缺失,同一个错误码不能代替这一区分。
- 程序成功但值不对:保留实际 stdout 和输入,继续看 JavaScript 运行语义与已有类型检查结果。number 标注不能保证运行时从外部收到的值就是数字。
错误码含义可对照 ERR_PACKAGE_IMPORT_NOT_DEFINED 与 ERR_MODULE_NOT_FOUND。Node 的 TypeScript 模块系统说明还列出文件扩展名与模块格式要求;本文通过显式 type=module 和 .ts 目标保持这一部分固定。
若让编码助手协助现有项目,可以先给它这个有限调查任务。下列文字是建议输入,本实验没有调用模型验证它的效果。
请先调查这个原生 Node TypeScript 入口的导入失败,不安装依赖。
记录实际 Node 版本、启动命令、环境中的加载器选项和完整错误码。
找到报错导入文件所在包的 package.json,核对模块说明符与真实目标文件。
区分 tsconfig paths 与 Node package.json imports 的读取者。
先在临时单包复现,再提出最小修改;不要改已有类型检查来隐藏失败。
报告哪些结果已运行验证,哪些仍属于编辑器、编译或部署链路的未验部分。12. 资料与版本:把历史提问和当前实验分开
2025-03-04 的 Stack Overflow 提问把运行时的路径别名错误理解为 VS Code 未识别配置;它展示的是 Node 16 启动 dist/app.js 的场景,不能当作本文原生 .ts 实验的复现记录。2025-07-02 的 TypeScript issue #61991则请求 rewriteRelativeImportExtensions 支持 paths 别名,截至核对日显示 closed as not planned。这些历史问题说明配置、改写与运行解析之间确有容易混淆的边界,并不提供搜索热度或增长数据。
技术依据以 2026-10-13 核对的 Node v24.19.0 版本文档和 TypeScript 官方 paths 页面为准。六个场景是本文新建并实际执行的合成输入;文章、测试、图解与代码均围绕这一个单包实验。SVG 已离线渲染检查,完整网页浏览器布局与真实 IDE 工作流未验证。
为什么 tsconfig paths 写对了,原生 Node 还是报错?
本例的 Node v24.19.0 原生类型剥离运行路径不读取 tsconfig.json。运行时仍须有自己能理解的映射;单包示例改用 package.json imports 的 #math。
package.json imports 能继续用 @app/math 这个名字吗?
本文使用的 Node 包内 imports 键以 # 开头,因此要把源码说明符一起改为 #math。它不会自动把已有 @ 别名改写成 # 名称。
#math 指向 dist/math.js 后,Node 会先编译它吗?
不会。本例故意不生成 dist/math.js,结果为 ERR_MODULE_NOT_FOUND。映射声明不负责生成产物;编译和部署输出需要各自验收。
能直接执行带 number 标注的函数,是否已经通过类型检查?
没有。本文把 add 的第一个参数换成字符串 "2",仍退出 0 并得到字符串 "23"。原生类型剥离不会替代静态类型检查或外部输入校验。
这个配置能直接用于 monorepo、框架或 npm 发布吗?
本文只验证单包 ESM 直接运行 .ts。跨包边界、框架加载器、编译产物和发布条件都需要单独设计及复测,不能从六个场景推导通用迁移方案。