为 Node.js TypeScript 包分离运行、类型检查与发布产物

用 TypeScript 写库时,开发环境里能运行 .ts 文件,只解决了一个问题。类型是否正确,需要另外检查;用户通过 npm 安装后能否加载入口,还取决于真正打进包里的 JavaScript、声明文件和元数据。这三条路径应当分别设计,再在持续集成中汇合。

本文合并译编 Node.js 官方三篇指南:Running TypeScript Natively、Running TypeScript code using transpilation 与 Publishing a TypeScript package。作者为 Node.js 文档贡献者。2026 年 10 月 5 日核对全文,并结合当日 Node.js API 文档纠正版本边界。本文完成到构建、类型检查和打包审查,不包含实际发布到 npm。

TypeScript源码分别进入Node原生运行、tsc类型检查和tsc产物构建三条路径;产物构建生成JavaScript和声明文件,再核对包入口与文件清单。
原创技术示意图:运行成功、类型检查通过和打包正确,需要不同证据。

原生执行:Node 擦除类型,但不检查类型

Node 的轻量 TypeScript 支持通过类型擦除工作:移除可擦除的类型标注、接口、类型别名和类型导入,执行留下的 JavaScript。Node 22.18.0 和 23.6.0 起在相应版本线上默认开启;API 历史记录显示该能力最初引入于 22.6.0,后来在 24.12.0 和 25.2.0 标为稳定。

node example.ts

旧指南说“22.18.0 以前加 --experimental-strip-types 即可”,这个说法必须限定在已经引入该功能的版本;Node 20 或更早的 22.x 不能靠一个不存在的标志获得支持。另一个时效性差异更明显:发布指南仍提到用 --experimental-transform-types 处理 enum,但核验时的 Node 26 API 文档明确记载该标志已在 26.0.0 删除。依赖代码生成语法的项目应使用专门的转译流程或合适的运行器,不应照搬旧标志。

原生擦除不读取 tsconfig.json,不根据 paths 重写导入,也不把新 JavaScript 语法降级为旧标准。普通 .ts 的模块类型按与 .js 相同的规则判断;ESM 项目应在 package.json 声明 "type": "module"。.mts 总是 ESM,.cts 总是 CommonJS,.tsx 不由此加载器支持。相对导入必须带扩展名,类型导入要写 import type。

import type { User } from './user.ts';
import { isAdult } from './is-adult.ts';

Node 不会因为变量类型不匹配而替你停止程序。enum、参数属性、含运行时代码的 namespace 和 import alias 等需要生成 JavaScript 的语法,也不是纯类型擦除能处理的内容。TypeScript 5.8+ 的 erasableSyntaxOnly 与 verbatimModuleSyntax 可帮助编辑器和编译器提前发现与这种运行方式不兼容的写法。

转译:从 .ts 生成 .js

另一条路径是先用 TypeScript 编译器生成 JavaScript,再交给 Node。原文给出如下示例:

type User = {
  name: string;
  age: number;
};

function isAdult(user: User): boolean {
  return user.age >= 18;
}

const justine = {
  name: 'Justine',
  age: 23,
} satisfies User;

const isJustineAnAdult = isAdult(justine);

安装本地开发依赖后,可执行 tsc,再运行生成的 example.js。这里没有 console.log,所以示例不会自动向终端打印 true;原文“会看到代码输出”的描述并不适用于这段原样代码。

npm install --save-dev typescript
npx tsc example.ts --noEmitOnError
node example.js

上面比原文多了 --noEmitOnError,用于在存在错误时阻止新产物生成。tsc 默认可能在报告类型错误后仍然输出 JavaScript,因此不能把“类型错误”表述成“绝不可能运行”。流水线还必须检查编译进程的非零退出码,且不能继续运行上一次遗留的旧产物。传入单个源文件与使用 tsc -p tsconfig.json 也不相同,工程构建应明确使用工程配置。

原文的错误演示将 age 改为字符串、给 isAdult 传入多余参数,并把布尔返回值赋给字符串变量。这些都是类型检查应报告的问题。类型系统能帮助发现不一致,但外部 JSON、HTTP 参数和数据库内容仍需运行时校验;类型声明本身不会过滤恶意输入。

把类型检查当作一种独立测试

单元测试检查给定输入下的运行行为,类型检查检查类型约束,二者互补。可以让 Node 直接执行源代码测试,同时让 tsc --noEmit 专门负责类型检查。原文的 CI 设计把类型检查放在一个 job,把运行测试放到多个 Node 版本与 macOS、Ubuntu、Windows 的矩阵中;如果依赖声明和编译器输入确实相同,类型检查通常没有必要随每个操作系统重复。

不过原文的构建配置排除了测试文件,只补了一句“测试也许有单独 tsconfig”,并未提供该配置。原样照抄后,CI 可能完全不检查测试代码的类型。以下是本文的改编配置,采用 src/ 与 test/ 分离的布局,并明确检查两者:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2023"],
    "module": "NodeNext",
    "strict": true,
    "noEmit": true,
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true
  },
  "include": ["src/**/*.ts", "test/**/*.ts"]
}

把它保存为 tsconfig.json。如果使用 Node 内置模块的类型,应同时安装与支持范围相匹配的 @types/node。项目需要固定并锁定 TypeScript 5.8 或更新的兼容版本;原文示例写 "typescript": "^5.7.2",而文字又建议 erasableSyntaxOnly,会让“实际安装哪一版”成为隐藏前提。本稿不提供未经安装验证的假锁文件。

测试可以与实现放在同一目录,也可放进相邻 __test__,或采用独立 test/。无论选哪种结构,都要让测试命令、类型检查范围和打包排除规则一致。开发测试直接运行 TypeScript 时,CI 应使用支持原生擦除的具体版本;发布产物支持的最低 Node 版本则可以另行选择,不能把二者混成一个版本要求。

构建 JavaScript 与声明文件

用户安装到 node_modules 后,Node 不会替包擦除其中的 TypeScript 类型。正式包应提供可执行的 JavaScript,并通过 .d.ts 等文件提供类型信息。main、exports、types 指向发布产物;scripts.test 可以继续指向源码。

声明文件是根据源码生成的伴随产物,通常可以在打包时生成,不必手工维护。原文提供平铺输出和 dist/ 输出两种方式;这里采用后者,便于明确检查交付边界。增加 tsconfig.build.json:

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "noEmit": false,
    "noEmitOnError": true,
    "rootDir": "./src",
    "outDir": "./dist",
    "declaration": true,
    "declarationMap": false
  },
  "include": ["src/**/*.ts"],
  "exclude": ["src/**/*.test.ts", "src/**/*.fixture.ts"]
}

这是与原文有差异的示例:增加 strict 与 noEmitOnError,显式分开检查/构建范围,并关闭声明映射。若需要 declarationMap,应同时规划映射引用的源码能否在发布包中找到,而不是生成指向缺失源文件的地图。输出目录中的旧文件也不能被默认为本轮构建产生,应在受控构建目录中确保产物来自当前提交。

{
  "name": "example-ts-pkg",
  "version": "0.0.0",
  "type": "module",
  "main": "./dist/main.js",
  "types": "./dist/main.d.ts",
  "exports": {
    ".": {
      "types": "./dist/main.d.ts",
      "import": "./dist/main.js"
    }
  },
  "files": ["dist", "README.md", "LICENSE"],
  "scripts": {
    "test": "node --test test/main.test.ts",
    "types:check": "tsc -p tsconfig.json --noEmit",
    "build": "tsc -p tsconfig.build.json",
    "prepack": "npm run build"
  }
}

该片段展示单入口 ESM 包,测试路径是布局示例,不代表已经存在一个测试文件;真实项目须替换成确切测试清单。它也不是 ESM/CommonJS 双产物配方,不能把一个 ESM 文件伪装成 require 入口。依赖与最低 Node 版本应在本项目实际验证后写入。原文称其转译配置可面向 Node 18+,并不等于 Node 18 在 2026 年仍处于支持期,也不证明代码使用的每个 API 都可在 Node 18 上运行。

检查真正进入包的文件

源码仓库包含测试、夹具、CI、配置和工作文件;发布包通常只需要 dist/*.js、dist/*.d.ts、package.json、README 与 LICENSE。原文建议通过 .npmignore 排除 TypeScript 源码和测试,同时用否定规则保留声明文件:

*.*ts
!*.d.*ts
*.fixture.*

如果输出到 dist/,也可直接排除 src 和 test。本稿的 package 示例改用 files 白名单;这不是免检机制,漏掉运行时资源会使包不可用。无论用黑名单还是白名单,都要核对入口、声明、许可证、必要数据文件以及源码映射,不让凭证、私有测试数据或不应公开的文档被带入包中。

npm pack --dry-run 可显示将被打包的文件,但 dry-run 不表示不会执行脚本:原文指出 prepack 会在 npm pack --dry-run 和 npm publish 之前运行。安装依赖的 npm ci 也可能执行生命周期脚本。先检查 scripts 与依赖来源,再在没有凭证、个人文件和生产目标的隔离工作区中运行;本文未执行这些命令。node --run 不自动执行相应 pre/post 生命周期,不能因为名称相似就替代 npm 的完整流程。

CI 负责准备与验证,发布是另一项动作

原文用 npm ci 安装锁定依赖,用独立 job 检查类型,再跨平台运行测试。它借助第三方 Action 动态选择活跃 Node 版本,但引用写作 ljharb/actions/node/matrix@main;这个可变化分支是供应链信任点。采用该设计前应审查 Action 并固定完整提交 SHA;官方 actions/checkout、actions/setup-node 的标签同样不等于不可变内容。不要凭空填写一个“固定 SHA”,应从审核过的实际提交取得,并让依赖更新工具持续提出可审查的更新。

类型检查、测试和构建 job 应默认只获得读取仓库所需的权限。来自外部贡献者的代码不应拿到发布凭证。原文的 publish.yml 由符合 **@* 的标签触发,但实际 npm publish 一行仍被注释;它展示的是发布准备框架,不能据此宣称“自动发布已完成”。本稿也没有创建工作流、上传包或验证 npm 账户。

可靠的最终检查是:源码类型检查覆盖实现和测试;运行测试在所声明的版本范围上通过;构建退出码为零;包内每个公开入口与类型路径都能解析;发布清单没有遗漏必需资源,也没有多带私密文件。每一项都需要自己的证据,不能用一次 node example.ts 成功替代。

来源与许可:原文三篇由 Node.js 文档贡献者提供;网站仓库许可证为 MIT,Copyright Node.js Website WG contributors,完整条文保留在 sources/LICENSE.txt。API 文档用于版本校正。本文调整了示例结构、补充错误阻止生成与测试类型覆盖,并标注原文过时标志;未声称代码已执行。委托方于 2026-10-05 确认全文翻译、转载及配图授权。原创图:未完纪编辑。静态审查不保证不存在漏洞。

保留的完整许可声明

以下为原项目适用许可文本,原作者与文档归属及本文改动说明见正文。

MIT License

Copyright Node.js Website WG contributors. All rights reserved.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容