运行 TypeScript

2026-09-09 00:00    #typescript  

TypeScript 7 之后,“怎么把 .ts 跑起来"的答案变了。ts-node 已经失效,Node 原生支持成为新选项。这一篇讲清楚现状和取舍。

先看结论

方式类型检查覆盖语法依赖推荐
tsc --noEmit + node x.ts✓(分开)仅可擦除无★★★
tsx x.ts✗全部1 个★★★
ts-node✓全部—✗ 已失效
tsc 编译后 node dist/x.js✓全部无★★(构建场景)

推荐组合:类型检查与执行分离

1# 终端 1:持续类型检查
2npx tsc --noEmit --watch
3
4# 终端 2:直接执行(Node 原生,零依赖)
5node --watch src/index.ts

tsc 负责类型,Node 负责执行。零额外依赖、启动快、职责清晰。

Node 原生 type stripping

Node.js 从 v22.18 / v23.6 起默认启用 type stripping:直接删除类型注解然后执行。

关键限制:它只删除,不转换。所以只支持"可擦除语法”。

 1// Node 原生 type stripping:可擦除 vs 不可擦除
 2//
 3// 这个文件本身是纯可擦除的,可以直接运行:
 4//   node content/typescript/src/running-ts/erasable-vs-not.ts
 5
 6// ---- 可擦除:Node 原生能跑 ----
 7
 8interface User { name: string; age: number }
 9type ID = string | number;
10type Mapper<T> = (item: T) => string;
11
12class Greeter {
13  // 注意:不能用参数属性(constructor(public name: string)),那不可擦除
14  name: string;
15  constructor(name: string) { this.name = name; }
16  greet(): string { return `hi ${this.name}`; }
17}
18
19const u: User = { name: "rainboy", age: 18 };
20const id: ID = 1;
21const mapper: Mapper<User> = (x) => x.name;
22const asUser = u as User;              // 类型断言可擦除
23const list: Array<number> = [1, 2];    // 泛型可擦除
24
25// implements / declare / import type 都可擦除
26class Impl implements User {
27  // 注意:这里不能用参数属性,必须显式声明字段 + 构造函数赋值
28  name: string;
29  age: number;
30  constructor(name: string, age: number) {
31    this.name = name;
32    this.age = age;
33  }
34}
35const impl: User = new Impl("a", 1);
36
37// ---- 不可擦除:Node 原生会报 ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX ----
38//
39// 下面这些**不能**写进这个文件,否则整个文件跑不起来。
40// 每一项都经过实测确认:
41//
42// 1. enum E { A, B }
43//    → TypeScript enum is not supported in strip-only mode
44//
45// 2. const enum E { A = 1 }
46//    → 同样报错
47//
48// 3. namespace N { export const a = 1 }
49//    → 同样报错
50//
51// 4. class C { constructor(public x: number) {} }
52//    → 参数属性,同样报错
53//
54// 5. class C { @dec m() {} }
55//    → 装饰器,连解析都失败:SyntaxError: Invalid or unexpected token
56//
57// 6. import fs = require("fs")
58//    → 同样报错
59
60console.log(new Greeter(u.name).greet(), mapper(asUser), id, list.length, impl.name);
1hi rainboy rainboy 1 2 a

实测:不可擦除语法会失败

语法Node 原生的结果
enum E { A }ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX
const enum E { A = 1 }ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX
namespace N { export const a = 1 }ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX
constructor(public x: number)ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX
装饰器SyntaxError: Invalid or unexpected token
import x = require("...")ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX

完整错误示例:

1$ node file-with-enum.ts
2SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode
3    code: 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX'
4
5$ node file-with-param-property.ts
6SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter property is not supported in strip-only mode
7    code: 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX'

可擦除语法清单

Node 原生能跑的:

原理见 类型擦除。

参数属性是最容易踩的坑

constructor(public x: number) 看起来只是"加个修饰符",但它会生成 this.x = x,所以不可擦除。改写方式:

1// ✗ Node 原生跑不了
2class C { constructor(public x: number) {} }
3
4// ✓ 可擦除
5class C {
6  x: number;
7  constructor(x: number) { this.x = x; }
8}

代价是代码变长。所以"用 Node 原生"和"用参数属性"只能选一个。

tsx:覆盖全部语法

需要执行 enum、namespace、参数属性、装饰器时,用 tsx:

1$ npx tsx src/index.ts

同一份含 enum + 参数属性 + namespace 的文件:

1$ npx tsx t.ts
20 5 1                    # ✓ 正常执行
3
4$ node t.ts
5SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]   # ✗ 拒绝

tsx 基于 esbuild,速度很快。但注意两点:

  1. 它不做类型检查。类型错误不会拦你,要另跑 tsc --noEmit。
  2. 它不实现 emitDecoratorMetadata。nestjs 这类依赖元数据的框架不能用 tsx 直接跑(见 装饰器(legacy))。

关于装饰器,实测结论:

装饰器类型tsx 支持
标准装饰器(TC39 stage-3)✓ 可以跑
legacy 装饰器(experimentalDecorators)✓ 可以跑
legacy + emitDecoratorMetadata✗ 元数据丢失

也就是说 tsx 能执行装饰器本身,只是拿不到类型元数据。

ts-node 为什么失效

ts-node@10 读取 ts.sys 来访问文件系统 API,而 TypeScript 7 不再暴露这个对象。实测:

1$ npx ts-node@10 run.ts
2TypeError: Cannot read properties of undefined (reading 'fileExists')
3    at readConfig (.../ts-node/dist/configuration.js:91:33)

这不是配置问题,无法绕过。原因是 TS 7 是 Go 原生重写版,没有稳定的 programmatic API(新的 API 计划在 7.1)。

同样受影响的还有:

Microsoft 提供了 @typescript/typescript6 兼容包,让需要旧 API 的工具能和 7.0 并存。

迁移建议

如果项目在用 ts-node:

  1. 开发执行改用 tsx 或 Node 原生
  2. 测试改用 swc/jest 或 vitest
  3. 类型检查始终用 tsc --noEmit(这个不受影响)

如果必须留在 TS 5.x/6.x,ts-node 仍然可用——它只是不能配 TS 7。

调试配置

VSCode 的 .vscode/launch.json,用 Node 原生:

 1{
 2  "version": "0.2.0",
 3  "configurations": [
 4    {
 5      "type": "node",
 6      "request": "launch",
 7      "name": "Debug TS (Node native)",
 8      "program": "${file}",
 9      "runtimeArgs": ["--experimental-strip-types"],
10      "console": "integratedTerminal",
11      "skipFiles": ["<node_internals>/**"]
12    }
13  ]
14}

用 tsx:

1{
2  "type": "node",
3  "request": "launch",
4  "name": "Debug TS (tsx)",
5  "runtimeArgs": ["--import", "tsx"],
6  "program": "${file}",
7  "console": "integratedTerminal"
8}

编译后调试(sourceMap)

需要断点映射回源码时:

1{
2  "compilerOptions": {
3    "sourceMap": true,
4    "inlineSources": true
5  }
6}

launch.json 指向编译产物,Node 会通过 .js.map 映射回 .ts。

生产构建

1# 方案一:tsc 编译
2npx tsc
3node dist/index.js
4
5# 方案二:打包工具转译 + tsc 把关
6npx tsc --noEmit && npx esbuild src/index.ts --bundle --outfile=dist/index.js
为什么方案二常见

esbuild/swc 的速度来自"单文件转译"——它们不做类型检查,只是删掉类型语法。所以构建快,但类型错误不会拦你。标准做法是让它们负责产出,tsc --noEmit 负责把关。

相关