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 版本。

只有 paths 时 @app/math 查包失败;Node imports 把 #math 映射到实际 .ts 文件后得到5
图 1:原生 Node 实验中的两条解析路径,原创示意图。

2. 把失败缩成一个临时单包

下面是 paths-only 场景的完整四文件输入。可以在一个新建的空目录手工保存,或者使用后文的 fixtures.mjs 自动生成到系统临时目录。不要覆盖已有项目的 package.json。四个文件的相对位置是:

package.json
tsconfig.json
src/main.ts
src/math.ts

package.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: 0

Node 的 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,没有 imports1;空;ERR_MODULE_NOT_FOUND
package-imports导入 #math;imports 指向 ./src/math.ts0;{"sum":5};无
wrong-tsconfig-pathimports 不变;paths 的 #math 指向 ./does-not-exist.ts0;{"sum":5};无
missing-imports-map导入 #math;删除 imports;保留 paths 的正确目标1;空;ERR_PACKAGE_IMPORT_NOT_DEFINED
missing-dist-fileimports 的 #math 指向不存在的 ./dist/math.js1;空;ERR_MODULE_NOT_FOUND
wrong-argument-type恢复成功映射;调用 add("2", 3)0;{"sum":"23"};无

第三项最能排除“恰巧读到了 paths”的猜测:我们把它的 #math 目标改坏,程序仍通过。第四项反过来删除 Node 映射,即使 paths 给出正确目标也救不回来。第五项中映射已经存在,但它指向的 dist/math.js 根本没有生成;声明映射不会替你编译文件。

六个实验分别观察配置未读取、映射成功、错误配置被忽略、缺少映射、缺少目标与错误类型值
图 2:固定输入对应的实际结果。完整堆栈因临时路径不同而变化,测试比较退出码、输出和错误码。

6. 最后一个成功,暴露了类型检查的边界

src/math.ts 的参数和返回值都写了 number,但第六项把第一个参数换成字符串 "2",实际仍退出 0,得到 {"sum":"23"}。类型标注被移除后,函数体还是 a + b;JavaScript 于是执行字符串拼接。这个合成反例说明,启动成功不能充当静态类型检查的通过记录。

add(2,3) 得数字5;add("2",3) 得字符串23;两次均退出0
图 3:函数体相同,类型标注没有生成运行时参数校验。

接到真实项目时,保留项目已有的类型检查步骤,以及外部输入需要的运行时校验。这里没有安装 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.mjs

demo 的实际完整 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。跨包边界、框架加载器、编译产物和发布条件都需要单独设计及复测,不能从六个场景推导通用迁移方案。