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 原生能跑的:
- 类型注解、
interface、type - 泛型参数与泛型调用
- 类型断言
as、<T>x implements、declareimport type/export type- 可选参数
?、readonly、可见性修饰符(不带参数属性) satisfies
原理见 类型擦除。
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,速度很快。但注意两点:
- 它不做类型检查。类型错误不会拦你,要另跑
tsc --noEmit。 - 它不实现
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)。
同样受影响的还有:
ts-jest@29:peer 依赖锁在typescript <7- 自定义 AST transformer 插件:会在编译中途失败,错误信息可能很误导
Microsoft 提供了 @typescript/typescript6 兼容包,让需要旧 API 的工具能和 7.0 并存。
如果项目在用 ts-node:
- 开发执行改用
tsx或 Node 原生 - 测试改用
swc/jest或 vitest - 类型检查始终用
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 负责把关。
相关
- 类型擦除 —— 可擦除与不可擦除的根本原因
- TypeScript 版本现状 —— TS 7 的变化与影响
- 工程化 —— tsconfig 与构建配置
- 装饰器(legacy) ——
emitDecoratorMetadata的限制