编程 TypeScript 7.0 深度拆解:Go 重写编译器 10 倍提速,确定性分片并行架构与 8 个能炸 CI 的迁移暗坑

2026-07-31 05:16:36 +0800 CST views 27

TypeScript 7.0 深度拆解:Anders Hejlsberg 用 Go 重写编译器,10 倍提速背后的并行架构与那些没人告诉你的迁移暗坑

2026 年 7 月 8 日,Daniel Rosenwasser 在 TypeScript 官方博客上敲下了一句话:

Today we are proud to announce the availability of TypeScript 7, a 10x faster native port of TypeScript!

从 2025 年 3 月 Anders Hejlsberg 宣布「A 10x Faster TypeScript」,到 7.0 正式版落地,整整 16 个月。这是 TypeScript 14 年历史上最大的一次架构手术——不是加语法,不是改类型推断,而是把整个编译器从 TypeScript 自举重写成 Go

网上关于这件事的报道已经很多了,但绝大多数停留在「快了 10 倍,牛」这个层面。作为一个每天跟 CI 流水线和 monorepo 搏斗的程序员,我更关心三件事:

  1. 这 10 倍到底是怎么来的? 是 Go 本身快,还是并行化的功劳?哪些环节根本没法并行?
  2. 我的项目切过去会炸吗? 官方说「语义严格一致」,但 CHANGES.md 里躺着几十条 intentional changes,其中有几条能直接炸掉你的 CI。
  3. 切过去之后,我的开发速度真的会变快 10 倍吗? (剧透:不会,但原因很有意思。)

这篇文章把这三个问题挖到底。所有性能数据来自微软官方公布的基准,所有 breaking change 来自 microsoft/typescript-go 仓库的 CHANGES.md,代码示例我会明确标注哪些是可直接运行的、哪些是架构示意。


一、背景:tsc 的「中年危机」是怎么来的

1.1 一个尴尬的事实:TypeScript 编译器是用 TypeScript 写的

这在语言设计上叫「自举」(bootstrapping),是一种荣耀。Rust 编译器是 Rust 写的,Go 编译器是 Go 写的。但 TypeScript 的自举有个致命区别:

Rust 和 Go 的编译器最终跑在原生机器码上,TypeScript 的编译器跑在 V8 上。

这意味着 tsc 要承受 JavaScript 运行时的全部税收:

  • JIT 预热:每次启动 tsc,V8 都要重新解析、字节码化、然后才逐步 JIT 优化编译器自己的几十万行代码。短命进程根本吃不到 JIT 的红利。
  • GC 压力:类型检查会产生海量短命对象(Type 对象、Signature 对象、Symbol 表项)。V8 的分代 GC 在这种负载下频繁触发 minor GC。
  • 单线程:Node.js 的 worker_threads 只能通过结构化克隆或 SharedArrayBuffer 通信。而类型检查器的核心数据结构是一张巨大的、充满循环引用的对象图(Symbol → Declaration → Node → Symbol)。这种图既不能廉价序列化,也没法塞进 SharedArrayBuffer。所以 tsc 的类型检查这么多年一直是严格单线程的。
  • 内存布局:JS 对象是散落在堆上的、带隐藏类指针的字典。遍历一棵百万节点的 AST,缓存命中率惨不忍睹。

1.2 规模一上来,体验就崩了

看几个官方给出的、切换前的真实数字:

代码库规模TS 6.0 全量构建
VS Code~150 万行125.7s
Sentry139.8s
Bluesky24.3s

更要命的是编辑器体验。在 VS Code 自己的代码库上,从打开编辑器到看见第一个红波浪线,需要 17.5 秒。Slack 的工程师直接说,本地编辑器体验「几乎不可用」(unusable),大家干脆放弃本地类型检查,全部丢给 CI 跑——然后 CI 一次全量 type-check 要 7.5 分钟。

这不是「慢一点」的问题,这是工作流被摧毁的问题。当反馈环长到分钟级,程序员就会绕过它。绕过类型检查的 TypeScript,价值损失一大半。

1.3 为什么之前的优化都是隔靴搔痒

过去几年 TS 团队做过很多优化:incremental--build 项目引用、skipLibCheckisolatedDeclarations……本质上都是在减少要做的工作量,而不是提升单位工作的速度

这些优化的收益是有天花板的。skipLibCheck 帮你跳过 .d.ts,但你自己的 100 万行业务代码还是得逐行检查。项目引用帮你切分了单元,但每个单元内部还是单线程。

要突破天花板,只有一条路:换执行底座


二、为什么是 Go,不是 Rust

这是 2025 年 3 月最大的争议点。Rust 社区当时很不爽——「你们微软自己有 C#,社区喊了半天 Rust,结果选了 Go?」

Hejlsberg 团队的理由,拆开看其实非常工程化:

2.1 核心约束:必须能「逐行移植」

这是整个决策的根。官方在 7.0 公告里写得很清楚:

This port was done as faithfully as possible, writing new code while maintaining the structure and logic of the original codebase to keep results consistent and compatible between the two compilers.

不重构,保结构,逐行翻译。 这个决定听起来保守,实际上是这个项目能在 16 个月内落地的唯一原因。TypeScript 的类型检查器有十几年积累的、无数 edge case 堆出来的行为。任何「顺便重构一下」的念头,都会让语义漂移,然后被那几万个测试用例活活按死。

那么问题变成:哪门语言能让你把一份充满循环引用对象图、闭包、可变共享状态的 TypeScript 代码,逐行翻译过去?

  • Rust:所有权和借用检查器会让你在第一个 Symbol → Declaration → Symbol 循环引用上就卡死。你要么全上 Rc<RefCell<>>(那还不如用 GC 语言),要么推倒重来改成 arena + index 的设计——那就不是「逐行移植」了,那是重写。
  • C#:其实很合适(有 GC、有类、有闭包)。但 C# 的 AOT 编译体验、跨平台单文件分发、启动速度在当时都不如 Go。而且 Node 生态对 .NET 运行时的接受度是个现实问题。
  • Go:有 GC(对象图随便循环引用)、有结构体值类型(内存布局可控)、goroutine + 共享内存(关键!)、编译产物是单个静态二进制、交叉编译一条命令搞定。

2.2 决定性因素:共享内存多线程

这一点被严重低估了。

Node.js 的 worker 之间不共享堆。要并行类型检查,你得把 AST 和 Symbol 表序列化传过去——而这些数据结构的序列化成本,很可能高于类型检查本身。

Go 的 goroutine 共享同一个地址空间。多个 checker worker 可以直接读同一份已解析的 AST、同一份 lib.d.ts 类型信息,零拷贝。这是 Go 版本能做并行类型检查、而 JS 版本永远做不到的根本原因。

官方原话是 native code speed, shared memory multithreading——注意顺序,共享内存多线程和原生速度是并列的两个功劳,不是一个

2.3 代号:Strada 与 Corsa

内部叫法值得记一下,因为你在 issue 和源码注释里会频繁看到:

  • Strada = 原来的 TypeScript 编译器(TS 的原始代号)
  • Corsa = Go 版本(这次移植的代号)

对外统一叫 TypeScript 6(JS 版)TypeScript 7(原生版)


三、性能数据:把官方数字拆开看

3.1 全量构建(默认 --checkers 4)

代码库TS 6TS 7加速比
vscode125.7s10.6s11.9x
sentry139.8s15.7s8.9x
bluesky24.3s2.8s8.7x
playwright12.8s1.47s8.7x
tldraw11.2s1.46s7.7x

3.2 内存:不是「更快但更耗内存」

这点很反直觉。多线程通常意味着更多内存,但 TS 7 反而更省:

代码库TS 6TS 7变化
vscode5.2GB4.2GB-18%
sentry4.9GB4.6GB-6%
bluesky1.8GB1.3GB-26%
playwright1.0GB0.9GB-11%
tldraw0.6GB0.5GB-15%

原因是 Go 的内存布局比 V8 堆紧凑得多。一个 JS 对象带隐藏类指针、属性字典、可能的 boxing;Go 的 struct 是紧密排布的连续内存。省下来的部分,足以抵消多 worker 带来的重复开销。

3.3 加核就能更快:--checkers 8 的数据

代码库TS 6TS 7 (--checkers 8)加速比
vscode125.7s7.51s16.7x
sentry139.8s12.08s11.6x
bluesky24.3s2.01s12.1x
playwright12.8s1.16s11x
tldraw11.2s1.06s10.6x

vscode 从 11.9x 干到 16.7x。这说明默认的 4 个 checker 在大型代码库上是保守配置,本地开发机(8 核以上)值得手动调高。

3.4 真正改变工作流的是编辑器数字

编译快只是爽,编辑器快才是救命:

  • VS Code 代码库:打开编辑器到第一个错误,17.5s → 1.3s,13x
  • Canva:58s → 4.8s
  • Slack CI type-check:7.5min → 1.25min,merge queue 时间减少 40%
  • 微软 News Services 团队:每月省下 400 小时 CI 等待
  • 语言服务命令失败率下降 80%+,服务崩溃率下降 60%+

最后两个数字我觉得比速度更重要。tsserver 崩溃是老 TS 用户的集体创伤——写着写着智能提示突然全没了,只能 Restart TS Server。这个问题在 7.0 里被砍掉了六成。


四、架构拆解:10 倍是怎么分配的

4.1 编译流水线与并行边界

TypeScript 的编译流水线大致是:

读文件 → Scanner(词法) → Parser(AST) → Binder(符号表) → Checker(类型检查) → Emitter(输出 JS/.d.ts)

官方明确说了哪些能并行、哪些难:

Some of these steps, like parsing and emitting can mostly be done independently across files. As such, parallelization automatically scales well with larger codebases with relatively little overhead. But not every step in a TypeScript build is easily parallelizable.

拆开讲:

Parsing(易并行):每个文件独立解析成 AST,文件之间零依赖。这是完美的 embarrassingly parallel 任务,goroutine 一撒就完事。

Emitting(易并行):JS 输出基本是逐文件的语法变换,也基本独立。.d.ts 输出稍微复杂(需要类型信息),但配合 isolatedDeclarations 可以做到纯语法级别的独立输出。

Type checking(难并行):这是核心难点,值得单独一节。

4.2 类型检查并行化:确定性分片的设计权衡

这是整个 TS 7 里最精妙的设计。官方描述:

Most files end up relying on the same type information from their dependencies and the global scope, and so running type-checkers completely independently would be wasteful – both in computation and memory. On the other hand, type-checking occasionally relies on the relative ordering of information in a program, and so type-checking from scratch must always check the same files in an identical order to ensure the same results.

两个矛盾的约束:

  1. 不能完全独立:每个文件都依赖 lib.d.ts、依赖上游模块的类型。N 个 worker 各自从零解析一遍全局类型,纯属浪费。
  2. 必须顺序确定:类型检查有顺序依赖(比如类型推断的收敛、循环引用的解析顺序)。同样的输入必须按同样的顺序检查,否则你今天跑出 3 个错误,明天跑出 4 个。

微软的解法:

TypeScript 7.0 creates a fixed number of type-checker workers with their own view of the world. These type-checking workers may end up duplicating some common work, but given the same input files, they will always divide them identically and produce the same results.

翻译成人话:

  • 创建固定数量(默认 4 个)的 checker worker
  • 每个 worker 有自己的「world view」(自己的类型缓存)
  • 接受一部分重复工作(比如每个 worker 都要各自解析 lib.d.ts 的类型)
  • 换来的是:分片是确定的——同样的输入文件集,永远切成同样的 N 份,永远产出同样的结果

这是一个非常典型的用空间换确定性的工程权衡。为什么不共享类型缓存?因为共享可变缓存就需要锁,而类型检查的访问模式是高频细粒度读写,锁竞争会把并行收益吃光。不如让每个 worker 各自算一份。

一个必须记住的坑(官方原文):

In rare cases, varying the number of --checkers may surface order-dependent results.

改变 checker 数量,在极少数情况下会改变错误输出。 所以团队务必在 CI 和本地统一固定这个值,不然会出现「我本地过了 CI 挂了」的经典惨案。

下面用一段 Go 代码来示意这种确定性分片的思路(注意:这是我为了讲清楚思路写的示意代码,不是 typescript-go 的源码):

// 示意代码:确定性分片 + 固定 worker 数
// 关键点:分片函数只依赖 (文件列表, worker 数),不依赖调度时序

package main

import (
	"sort"
	"sync"
)

type SourceFile struct {
	Path string
	AST  *Node
}

type Diagnostic struct {
	File    string
	Pos     int
	Message string
}

// 确定性分片:先排序,再按固定规则切分
// 无论跑多少次、机器多少核,同样的输入必然得到同样的分片
func deterministicPartition(files []*SourceFile, n int) [][]*SourceFile {
	sorted := make([]*SourceFile, len(files))
	copy(sorted, files)
	sort.Slice(sorted, func(i, j int) bool {
		return sorted[i].Path < sorted[j].Path
	})

	shards := make([][]*SourceFile, n)
	for i, f := range sorted {
		idx := i % n // 轮询分配,纯函数,无随机性
		shards[idx] = append(shards[idx], f)
	}
	return shards
}

// 每个 worker 持有自己的 checker(自己的 world view)
// 代价:globalTypes 会在每个 worker 中重复构建一次
type Checker struct {
	globalTypes *TypeCache // 每个 worker 独立一份,避免锁竞争
	shard       []*SourceFile
}

func (c *Checker) CheckAll() []Diagnostic {
	var out []Diagnostic
	for _, f := range c.shard {
		// 分片内部严格按顺序检查,保证顺序依赖的行为一致
		out = append(out, c.checkFile(f)...)
	}
	return out
}

func ParallelCheck(files []*SourceFile, numCheckers int) []Diagnostic {
	shards := deterministicPartition(files, numCheckers)

	results := make([][]Diagnostic, numCheckers)
	var wg sync.WaitGroup

	for i := 0; i < numCheckers; i++ {
		wg.Add(1)
		go func(idx int) {
			defer wg.Done()
			ck := &Checker{
				globalTypes: buildGlobalTypes(), // 重复工作,但换来无锁
				shard:       shards[idx],
			}
			results[idx] = ck.CheckAll()
		}(i)
	}
	wg.Wait()

	// 关键:结果按分片索引顺序合并,而不是按完成顺序
	// 否则诊断信息的输出顺序会随机漂移
	var merged []Diagnostic
	for _, r := range results {
		merged = append(merged, r...)
	}
	return merged
}

这段示意代码里有三个细节,是并行编译器共通的坑:

  1. 分片前必须排序:文件系统遍历的顺序在不同 OS、不同 FS 上不一样,不排序就没有确定性。
  2. 每个 worker 独立缓存:牺牲内存换掉锁。
  3. 按索引合并而非按完成顺序合并:否则诊断输出顺序每次都变,diff 全是噪音。

4.3 三个旋钮:--checkers、--builders、--singleThreaded

TS 7 给了三个新 flag(--checkers--builders 目前标记为实验性):

# 类型检查 worker 数,默认 4
tsc --checkers 8

# 项目引用并行构建数(仅 --build 模式下有效)
tsc --build --builders 4

# 完全单线程:parsing / checking / emitting 全部串行
tsc --singleThreaded

关键警告:这两个 flag 有乘法效应。

官方原文:

building with --checkers 4 --builders 4 allows up to 16 type-checkers to run at once, which may be excessive.

4 个 builder × 每个 4 个 checker = 16 个类型检查器同时跑。在一个 8 核 16GB 的 CI runner 上,这会直接把内存打爆,然后你会看到 OOM kill 而不是编译错误。

另一个重要区别:

  • --checkers 变化 → 极少数情况下会改变结果
  • --builders 变化 → 不会改变结果(只受项目依赖图拓扑约束)

所以 --builders 可以放心根据机器调,--checkers 应该在项目里固定死。

--singleThreaded 的用途别忽视:

  • 调试编译器行为(排除并行导致的干扰)
  • 做 TS 6 vs TS 7 的公平性能对比
  • 外部已经在编排并行(比如 Nx / Turborepo 已经并发跑多个包),再叠一层并行只会互相抢核
  • 极端资源受限环境(容器 CPU limit 0.5 核的那种)

4.4 从 tsserver 到 LSP:被低估的重构

老 TypeScript 的编辑器集成走的是 tsserver 私有协议——一个只有 TS 团队自己懂的 JSON 协议。VS Code 有专门的适配层,其他编辑器(Neovim、Emacs、Sublime)要么自己写 shim,要么依赖第三方封装。

TS 7 直接换成了标准的 Language Server Protocol (LSP)。这件事的意义:

  1. 生态平权:任何支持 LSP 的编辑器,直接对接原生语言服务,不需要中间层。
  2. 可观测性:LSP 有标准的请求/响应模型,失败可以被统计。这也是官方能给出「命令失败率降 80%」这种数字的前提。
  3. 崩溃隔离:Go 的 goroutine panic 可以被 recover,而 Node 进程 OOM 就是直接死。崩溃率降 60% 里,有相当一部分是内存占用减半带来的。

VS Code 用户现在可以装官方的 native-preview 扩展,或者直接在设置里开:

{
  "js/ts.experimental.useTsgo": true
}

(注:tsgo 是 preview 期间的命令名,7.0 RC 之后命令名已统一为 tsc。你在老教程里看到 npx tsgo,对应现在的 npx tsc。)

4.5 watch 模式重建:Go 标准库的一个真实短板

这段官方博客里的自述很实在:

The standard library doesn't provide a built-in file watching API, and existing third-party libraries we explored had various issues with stability, performance, cross-platform support, or issues with build tooling integration.

Go 标准库没有文件监听 API。团队试过纯轮询方案,结论是:

it was computationally expensive, especially at larger-scale projects with many dependencies in node_modules. Even with dynamic scheduling strategies, we found that pure-polling solutions were too taxing for general use.

node_modules 又一次成为了万恶之源——几十万个文件轮询一遍,CPU 直接起飞。

最终方案是基于 Parcel bundler 的 file-watcher@parcel/watcher)重建 watch 模式。这个库 VS Code 用了很多年,底层对接各平台原生事件 API(Linux inotify / macOS FSEvents / Windows ReadDirectoryChangesW),是经过大规模生产验证的选择。

这个细节对我们的启发是:跨平台文件监听是一个远比想象中难的问题。如果你在自己的工具链里手搓 watcher,先去看看 @parcel/watcher 踩过的坑。


五、上手实战:安装、双版本共存、CI 配置

5.1 最简安装

npm install -D typescript
npx tsc --version
# 应该输出 7.x

对,就是正常的 typescript 包。7.0 已经占据了主版本位,npm install typescript 默认给你的就是 Go 版本。

5.2 关键坑:7.0 不带 API

这是目前最大的生态风险点,官方明说:

While TypeScript 7.0 is here, it does not ship with an API. We expect TypeScript 7.1 to ship with a new (and different) API.

也就是说,所有通过 import ts from "typescript" 编程调用编译器的工具,在 7.0 下全部不可用

  • typescript-eslint(类型感知规则全靠它)
  • ts-jest / ts-node / tsx 的部分路径
  • vue-tscsvelte-check 这类框架检查器
  • 各种 codemod、API Extractor、自研的类型分析脚本

而且注意措辞:7.1 的 API 是「new (and different)」——不是兼容的,是全新的。这意味着生态工具不是「升个依赖」就完事,而是要重写适配层。

5.3 双版本共存的标准姿势

官方给了兼容包 @typescript/typescript6,它提供 tsc6 可执行文件,并且重新导出 TS 6.0 的 API

因为像 typescript-eslint 这类工具是通过 peer dependency 直接 import "typescript" 的,所以正确做法是用 npm alias

{
  "devDependencies": {
    "@typescript/native": "npm:typescript@^7.0.2",
    "typescript": "npm:@typescript/typescript6@^6.0.2"
  }
}

这套配置的效果:

  • import ts from "typescript" → 拿到 TS 6 的 API(typescript-eslint 正常工作)
  • npx tsc → 跑的是 TS 7 的原生二进制(享受 10x 提速)
  • npx tsc6 → 显式跑 TS 6 编译器(用于比对)

如果只想装兼容包:

npm install -D typescript@npm:@typescript/typescript6
# 注意:这样只有 tsc6,没有 tsc

想跟 nightly:

npm install -D typescript@next

@typescript/native-preview 这个包完成历史使命了——它在预览期做到了 850 万周下载量,后续 nightly 回归标准 typescript 包的 next tag。)

5.4 一个可以直接用的「影子比对」脚本

切换前最该做的事:同时跑 TS 6 和 TS 7,diff 两边的错误集合。别信「语义一致」的承诺,自己验一遍。

#!/usr/bin/env bash
# shadow-check.sh —— TS6 / TS7 错误集合影子比对
# 前置:package.json 已按 5.3 节配置好 alias
set -uo pipefail

OUT_DIR=".ts-shadow"
mkdir -p "$OUT_DIR"

echo "==> [1/3] 运行 TypeScript 6 (tsc6)"
npx tsc6 --noEmit --pretty false 2>&1 \
  | grep -E '^\S+\([0-9]+,[0-9]+\): error' \
  | sort -u > "$OUT_DIR/ts6.txt" || true

echo "==> [2/3] 运行 TypeScript 7 (tsc, --checkers 固定为 4)"
npx tsc --noEmit --pretty false --checkers 4 2>&1 \
  | grep -E '^\S+\([0-9]+,[0-9]+\): error' \
  | sort -u > "$OUT_DIR/ts7.txt" || true

echo "==> [3/3] 差异分析"
TS6_COUNT=$(wc -l < "$OUT_DIR/ts6.txt" | tr -d ' ')
TS7_COUNT=$(wc -l < "$OUT_DIR/ts7.txt" | tr -d ' ')
echo "TS6 错误数: $TS6_COUNT"
echo "TS7 错误数: $TS7_COUNT"

echo ""
echo "--- 仅 TS7 报错(新增,需重点排查)---"
comm -13 "$OUT_DIR/ts6.txt" "$OUT_DIR/ts7.txt" | head -50

echo ""
echo "--- 仅 TS6 报错(TS7 不再报,通常是修了 bug)---"
comm -23 "$OUT_DIR/ts6.txt" "$OUT_DIR/ts7.txt" | head -50

DIFF_COUNT=$(comm -13 "$OUT_DIR/ts6.txt" "$OUT_DIR/ts7.txt" | wc -l | tr -d ' ')
if [ "$DIFF_COUNT" -gt 0 ]; then
  echo ""
  echo "⚠️  有 $DIFF_COUNT 条 TS7 新增错误,对照 CHANGES.md 逐条确认后再切换"
  exit 1
fi
echo "✅ 错误集合一致,可以推进切换"

把这个脚本挂在 CI 上跑一周,你就知道自己的代码库到底能不能平滑切换。

5.5 --checkers 调参:别拍脑袋,压一遍

#!/usr/bin/env bash
# bench-checkers.sh —— 扫描不同 checker 数的耗时与峰值内存
set -euo pipefail

CORES=$(getconf _NPROCESSORS_ONLN 2>/dev/null || sysctl -n hw.ncpu)
echo "本机核数: $CORES"
printf "%-10s %-12s %-12s\n" "checkers" "耗时(s)" "峰值内存(MB)"

for N in 1 2 4 8 12 16; do
  if [ "$N" -gt $((CORES * 2)) ]; then continue; fi

  # 清 incremental 缓存,保证是全量构建
  rm -rf ./node_modules/.tmp/tsbuildinfo tsconfig.tsbuildinfo 2>/dev/null || true

  START=$(date +%s.%N)
  if command -v /usr/bin/time >/dev/null 2>&1; then
    MEM_KB=$(/usr/bin/time -f "%M" npx tsc --noEmit --checkers "$N" 2>&1 >/dev/null | tail -1)
  else
    npx tsc --noEmit --checkers "$N" >/dev/null 2>&1 || true
    MEM_KB=0
  fi
  END=$(date +%s.%N)

  ELAPSED=$(echo "$END - $START" | bc)
  MEM_MB=$((MEM_KB / 1024))
  printf "%-10s %-12.2f %-12s\n" "$N" "$ELAPSED" "$MEM_MB"
done

调参经验法则(结合官方数据和并行计算的一般规律):

场景建议理由
本地开发机(8-16 核,32GB+)--checkers 8官方数据显示 vscode 级别代码库能从 11.9x 提到 16.7x
标准 CI runner(2 核 7GB)--checkers 2 甚至 1核少的时候,worker 的重复工作反而是净亏损
大型 monorepo --build--builders 4 --checkers 2控制乘积 ≤ 核数,避免 OOM
容器内 CPU limit < 2--singleThreaded并行调度开销大于收益
调试诡异的类型错误--singleThreaded排除顺序依赖的干扰

再强调一次:把最终选定的 --checkers 值写进 tsconfig.json 或者 npm script,全团队统一。别让它成为环境变量。

5.6 GitHub Actions 配置示例

name: typecheck

on: [push, pull_request]

jobs:
  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'

      - run: npm ci

      # 标准 runner 是 2 核,checkers 设成 2,多了纯浪费
      - name: Type check (TS7)
        run: npx tsc --noEmit --checkers 2

      # 过渡期:并行跑一份 TS6 做交叉验证,允许失败不阻塞
      - name: Shadow check (TS6)
        run: npx tsc6 --noEmit
        continue-on-error: true

      # typescript-eslint 依赖 TS6 API,通过 alias 正常工作
      - name: Lint
        run: npx eslint .

5.7 配合 isolatedDeclarations 榨干 --builders

官方提到一个重要的例外:

building project references is fundamentally bottlenecked by the dependency graph of projects (with the exception of type-checking on codebases that leverage --isolatedDeclarations and separate syntactic declaration file emit).

意思是:--builders 的天花板是项目依赖图。如果 A → B → C 是一条链,你开 10 个 builder 也没用,必须串着来。

但如果你开了 isolatedDeclarations.d.ts 可以纯靠语法生成,不需要等上游类型检查完成。这样依赖链就被打断了——所有项目的 .d.ts 可以先并行生成出来,然后所有项目的类型检查也可以并行跑。

// tsconfig.base.json
{
  "compilerOptions": {
    "isolatedDeclarations": true,  // 强制显式标注导出类型
    "declaration": true,
    "composite": true,
    "incremental": true
  }
}

代价是:所有导出的函数/变量必须显式写返回类型和类型标注,不能靠推断。

// ❌ isolatedDeclarations 下报错:无法在不做类型推断的前提下确定返回类型
export function createUser(name: string) {
  return { id: crypto.randomUUID(), name, createdAt: Date.now() };
}

// ✅ 显式标注
export interface User {
  id: string;
  name: string;
  createdAt: number;
}

export function createUser(name: string): User {
  return { id: crypto.randomUUID(), name, createdAt: Date.now() };
}

对大型 monorepo 来说,这个改造的一次性成本不小(几百个导出要补标注),但换来的是构建图从串行链变成并行扇出,收益是长期的。可以用 --fix 类的 codemod 批量补,也可以按包逐步开启。


六、迁移暗坑:CHANGES.md 里那些能炸 CI 的条目

官方说「语义严格一致」,这话对纯 TypeScript 代码基本成立。但 microsoft/typescript-go 仓库里有一份 CHANGES.md,专门列举「intentional changes」。这份文档才是迁移的真正说明书。

我挑出杀伤力最大的几条。

6.1 【高危】skipLibCheck 下的冲突声明现在全都报错

CHANGES.md 原文:

In TypeScript 6, conflicting declarations would sometimes only error in one declaration involved in the conflict. In TypeScript 7, conflicting declarations now consistently error in all contributing sites. This means some projects with errors previously fully silenced by --skipLibCheck will now correctly see declaration conflict errors in non-.d.ts files.

这一条我认为是最容易在切换第一天就炸 CI 的。

场景:你的项目有两个 .d.ts 声明了同名的全局类型(比如两个库都 declare global 了同一个东西),TS 6 只在 .d.ts 里报错,然后被 skipLibCheck 静音了,你根本不知道有这个问题。

TS 7 会在所有参与冲突的位置报错——包括你自己的 .ts 文件。而 skipLibCheck 管不到 .ts 文件。

于是:升级 → CI 冒出一堆你从没见过的 Duplicate identifier / Subsequent variable declarations must have the same type

排查方法:切换前先在 TS 6 下临时关掉 skipLibCheck 跑一次,把潜伏的声明冲突捞出来。

npx tsc6 --noEmit --skipLibCheck false 2>&1 | grep -E "Duplicate|Subsequent" | sort -u

6.2 【中危】模板字面量类型推断改用 Unicode code point

这条是类型体操党的噩梦,也是我觉得最有意思的一条。

CHANGES.md:

type Head<S> = S extends `${infer H}${string}` ? H : never;
type Rest<S> = S extends `${string}${infer R}` ? R : never;

// TypeScript 6 (Strada):按 UTF-16 code unit 推进
type H = Head<"😀abc">; // "\uD83D"  ← emoji 被劈成代理对的一半
type R = Rest<"😀abc">; // "\uDE00abc"

// TypeScript 7 (Corsa):按 Unicode code point 推进
type H = Head<"😀abc">; // "😀"      ← 完整
type R = Rest<"😀abc">; // "abc"

TS 7 的行为显然更正确。但如果你的代码里有基于字符串字面量类型做递归拆解的类型工具(比如路由参数解析 /user/:id、CSS-in-TS 的属性名校验、i18n key 的类型推导),而输入里可能出现 emoji 或者其他 supplementary plane 字符(中日韩扩展区的生僻字也在这个范围),推导结果会变。

这类改动不会报错,只会静默给出不同的类型,然后在某个下游断言处炸出来。排查成本极高。

自查方法:全局搜 infer 配合模板字面量的类型,逐个确认输入域。

6.3 【高危,对工具作者】节点位置从 UTF-16 偏移改成 UTF-8 偏移

Node positions use UTF8 offsets from the beginning of the file, not UTF16 offsets. Node positions in files with non-ASCII characters will be greater than before.

对普通业务开发者无感,对工具作者是硬伤

如果你写过:

  • codemod 脚本(基于 node.pos / node.end 做字符串切片)
  • 自定义 lint 规则
  • 代码生成器
  • 编辑器插件

所有位置计算都要重新对齐。中文注释、中文字符串一多,偏移量差异会非常大(一个中文字符 UTF-16 是 1 个 unit,UTF-8 是 3 个 byte)。

而 JS 的 String.prototype.slice 是按 UTF-16 code unit 走的。拿着 UTF-8 偏移去切 JS 字符串,切出来的是乱码。

这一条也解释了为什么 7.0 干脆不发 API——位置模型都变了,老 API 根本没法兼容

6.4 【中危】JavaScript / JSDoc 支持大幅收缩

如果你的项目是 allowJs: true + JSDoc 类型标注的老代码库,这块要重点看。

CHANGES.md 的设计原则很明确:

JavaScript support in Corsa is intended to expose TypeScript features in a .js file, working exactly as they do in TypeScript with different syntax.

翻译:JS 里只能用 TS 有的能力,Closure 时代的那套遗产全砍

最大的一刀:构造函数式 expando 不再支持

// ❌ TS 7 不再支持
function C() {
  this.property = 1;
}
C.prototype.method = function() {};

// ✅ 必须改成 class
class C {
  constructor() {
    this.property = 1;
  }
  method() {}
}

对于还在维护 ES5 风格 JS 的项目,这可能意味着大面积改写。

其他被移除的 Closure 特性

特性旧写法替代
? 未知类型/** @type {?} */any
Closure 函数类型/** @type {function(string): void} *//** @type {(s: string) => void} */
@enum/** @enum {number} */ const E = {...}@typedef + Record<string, E>
@class / @constructor装饰函数当构造器class
namepathModule:file~idimport("file").id
自动 typeof/** @type {o} *//** @type {typeof o} */

@author 的坑很隐蔽

/** @author Finn <finn@treehouse.com> */

@treehouse 会被 Corsa 解析成一个新的 JSDoc tag。写法保持不变即可,但如果你有基于 JSDoc AST 的工具,要注意这个解析差异。

JSDoc 变长参数不再自动变成 rest 参数

// ❌ TS 7:ns 是普通参数,类型是 number[]
/** @param {...number} ns */
function sum(ns) {}

// ✅ 必须用标准 rest 语法
/** @param {...number} ns */
function sum(...ns) {}

arguments 不再隐含 ...args: any[]

// ❌ TS 7 报错
function f() {
  return arguments[0];
}
f("something");

// ✅
function f(...args) {
  return args[0];
}

JSDoc 类型位置不再解析值

// ❌
/** @typedef {FORWARD | BACKWARD} Direction */
const FORWARD = 1, BACKWARD = 2;

// ✅ 必须加 typeof,跟 TS 一致
/** @typedef {typeof FORWARD | typeof BACKWARD} Direction */
const FORWARD = 1, BACKWARD = 2;

6.5 【中危】strict: false 下不能再省略 unknown / any 参数

/** @param {unknown} x */
function f(x) {
  return x;
}
f(); // TS 6: 允许;TS 7: 报错

undefinedunknownany 类型的参数不能再省略。只有 void 还能省:

/** @param {void} x */
function f(x) { return x; }
f(); // 仍然允许

对于关了 strict 的老项目,这条会批量报错。

6.6 【中危】CommonJS 的 module.exports 不能混写

Corsa does not permit CommonJS modules to mix assignments to the full module.exports with assignments to module.exports.xxx properties.

// ❌ TS 7 不允许混用
module.exports = createThing;
module.exports.helper = helper;   // 二选一

// ✅ 方案 A
module.exports = Object.assign(createThing, { helper });

// ✅ 方案 B
module.exports.createThing = createThing;
module.exports.helper = helper;

module.exports = fn; module.exports.xxx = ... 这个模式在老 npm 包里极其常见(Express 中间件、各种 util 库都这么写)。如果你在 allowJs 下检查这些代码,会集中爆雷。

另外这几条也值得注意:

// 忽略空的 module.exports 赋值 —— 现在不再特殊处理,直接删掉这行
module.exports = {};

// 单属性 require 解构
var readFile = require('fs').readFile;   // 改成
var { readFile } = require('fs');

// module.exports 别名
var mod = module.exports; mod.x = 1;     // 改成
module.exports.x = 1;

6.7 【低危但会困惑】@type 断言不再触发收窄

/** @param {C | undefined} cu */
function f(cu) {
  if (/** @type {any} */ (cu).undeclaredProperty) {
    cu; // TS 6: 收窄成 C(这是 bug)
        // TS 7: 仍然是 C | undefined(跟 TS 语法行为一致)
  }
}

TS 7 修正了这个不一致。但如果你的代码依赖了这个 bug 行为,升级后会冒出 Object is possibly 'undefined'

6.8 迁移检查清单

把上面的内容压成一张可执行的表:

#检查项命令 / 方法风险
1声明冲突(skipLibCheck 掩盖)tsc6 --noEmit --skipLibCheck false🔴 高
2依赖 typescript API 的工具清单grep -rn "from ['\"]typescript['\"]" --include=*.ts .🔴 高
3工具链位置计算(node.pos/end)人工审查 codemod / 自定义 lint🔴 高(仅工具作者)
4模板字面量类型递归infer + 反引号模板类型🟡 中
5JS 构造函数式 expando\.prototype\.\w+\s*=🟡 中(仅 allowJs)
6CommonJS module.exports 混写module\.exports\s*= 并检查同文件是否有 module.exports.🟡 中
7Closure 风格 JSDoc@enum@classfunction( in JSDoc🟡 中
8影子比对错误集合5.4 节脚本✅ 必做
9checkers 值全团队固定写进 npm script✅ 必做

七、生产落地路线图:分四步,别一把梭

基于上面的风险分析,我建议这样推进:

阶段 0:影子期(1-2 周)

  • CI 里加一个 continue-on-error: true 的 TS 7 job
  • 每天收集 5.4 节脚本的 diff 输出
  • 对照 CHANGES.md 逐条归因,不急着改代码

退出条件:diff 稳定,且每一条都能解释清楚原因。

阶段 1:编辑器先行(1-2 周)

  • 让几个志愿者在本地开 js/ts.experimental.useTsgo
  • 这一步零风险:编辑器的类型提示不影响构建产物
  • 收益立竿见影:项目加载从几十秒到几秒

这一步是最划算的。即使 CI 还没切,开发体验已经变了。

阶段 2:CI 类型检查切换(1 周)

  • tsc --noEmit 换成 TS 7,固定 --checkers
  • 保留 tsc6 --noEmit 作为 continue-on-error 的对照组
  • 观察一个完整的发布周期

注意:这一步只切 type check,不切 emit。构建产物还是老的。

阶段 3:切换 emit(谨慎)

  • 如果你的构建走 esbuild / swc / Rolldown(大多数现代项目都是),那 tsc 本来就只负责 --noEmit 类型检查和 .d.ts 生成,这一步压力很小
  • 如果你还在用 tsc 直接输出 JS,务必比对产物:
npx tsc6 --outDir /tmp/out6
npx tsc  --outDir /tmp/out7
diff -r /tmp/out6 /tmp/out7

特别注意 .d.ts:CHANGES.md 明确说了,基于 .js 输入的声明生成「substantially changed behavior」,而且「it's a non-goal to exactly match Strada's output」。如果你发布 npm 包,.d.ts 的差异会直接影响下游用户。

阶段 4:等 7.1 API,迁移工具链

typescript-eslint 等工具适配新 API 之前,一直保留 @typescript/typescript6 alias。这可能要等几个月。


八、冷思考:10 倍之后,瓶颈去哪了

8.1 10x 是编译速度,不是研发速度

阿姆达尔定律永远在场。假设你的 CI 流水线是这样:

npm ci        90s
tsc --noEmit  120s
bundle        60s
unit test     180s
e2e           300s
────────────────────
总计          750s

tsc 从 120s 干到 12s,总时间从 750s 变成 642s——只快了 14%

真正的收益不在 CI 总时长,而在两个地方:

  1. 编辑器内的即时反馈(17.5s → 1.3s 这种量级的变化,改变的是工作方式,不是等待时间)
  2. 本地类型检查重新变得可行(Slack 的案例:以前只能靠 CI 检查,现在本地几秒就能跑完)

第二点被严重低估了。当本地 type check 只要 3 秒,你就会在 commit 前跑一遍;当它要 3 分钟,你就会直接 push 让 CI 去发现问题。反馈环的长度决定了它会不会被使用。

8.2 「不重构」是这个项目最正确的决定

我想再强调一次这个工程哲学。

一个理性的工程师看到 TypeScript 编译器十几年的历史包袱,第一反应一定是「重写的时候顺便把架构理一理」。但 TS 团队选择了逐行翻译,保持原有结构和逻辑

代价是:Go 代码里保留了很多不符合 Go 惯用法的写法(大量指针、巨型 switch、模仿 JS 对象图的结构体)。Go 社区里已经有人吐槽 typescript-go 的代码「不够 Go」。

收益是:几万个测试用例几乎全绿,语义漂移接近零,16 个月落地。

对比一下其他大型重写项目的下场就知道这有多难得。「重写 + 重构」同时做,等于同时改变两个变量,出了问题你根本不知道是移植的锅还是重构的锅。

这个经验可以直接套用到我们自己的项目上:大型迁移的第一原则是一次只改一个维度。 换语言就别改架构,改架构就别换语言。

8.3 API 缺席期是真正的风险窗口

7.0 不带 API,7.1 才有,而且是全新的、不兼容的 API。

这意味着未来几个月,整个 TS 工具生态处在一个尴尬状态:

  • 想要 10x 速度 → 用 TS 7 的 tsc
  • 想要 typescript-eslint / vue-tsc / ts-jest → 还得装 TS 6

官方给的 npm alias 方案能工作,但它本质上是同一份代码被两个不同版本的类型检查器检查。理论上语义一致,实践上你要面对「eslint 说这里有类型错误,但 tsc 说没有」这种诡异场景。

对于框架维护者(Vue、Svelte、Angular)来说,这个窗口期的适配压力非常大。作为使用者,选型时要把这个因素算进去:如果你重度依赖类型感知的 lint 规则,可以再等一两个版本。

8.4 对 AI 编程的意义可能比我们想的更大

Hejlsberg 在 2025 年那篇公告里有一句话,当时看没什么感觉,现在回头看很有预见性:

New experiences powered by AI benefit from large windows of semantic information that need to be available with tighter latency constraints.

AI coding agent 跟人类程序员的工作方式不一样。人类改一处,看一眼红波浪线;Agent 可能在几秒内生成几十个文件的修改,然后需要立刻知道类型是否成立。

如果每次全量检查要 2 分钟,Agent 的迭代循环就被卡死了。如果只要 2 秒,Agent 就可以「写 → 检查 → 修 → 再检查」快速收敛。

7.0 公告里也提到了这点:

When you (and more recently, perhaps an AI agent) were ready to build your project, you'd run tsc...

括号里那句「and more recently, perhaps an AI agent」透露了很多信息。类型系统正在从「给人看的文档」变成「给机器用的验证器」,而验证器的价值跟它的速度成正比。

从这个角度看,TS 7 与其说是给人类程序员提速,不如说是给 AI Agent 铺了一条高速公路

8.5 一个没被讨论的问题:确定性与可复现构建

--checkers 在极少数情况下会影响结果,这件事在可复现构建(reproducible build)的语境下是个瑕疵。

对绝大多数团队无所谓。但如果你在做:

  • 合规审计(要证明这次构建和上次一模一样)
  • 二进制可复现验证
  • 严格的 CI 缓存命中判定

那你需要在所有环境固定 --checkers,甚至考虑 --singleThreaded。官方也这么建议:

Specifying a fixed number of checkers across build environments can help ensure everyone is getting the same results.

这是并行编译器的通用代价——并行度成了构建输入的一部分


九、总结

回到开头的三个问题。

Q1:10 倍从哪来?

大致可以拆成三块:

  1. 原生代码 vs V8:没有 JIT 预热、没有 V8 GC 抖动、内存布局紧凑。这块贡献了基础的几倍。
  2. 共享内存并行:Go 的 goroutine 共享地址空间,让 parsing / checking / emitting 都能并行。这是 JS 版本架构上不可能做到的。默认 4 checker,调到 8 能再提 40%(vscode 从 11.9x → 16.7x)。
  3. 顺带的优化:内存占用降低 6%-26%,语言服务失败率降 80%、崩溃率降 60%。

Q2:切过去会炸吗?

纯 TypeScript 代码:基本安全,但一定要先跑影子比对,重点排查 skipLibCheck 掩盖的声明冲突。

allowJs + JSDoc 的老代码库:风险很高,Closure 遗产、构造函数式 expando、CommonJS 混写这几条会集中爆雷。

依赖 typescript API 的工具链:7.0 直接不可用,必须用 npm alias 保留 TS 6,等 7.1 的新 API。

Q3:研发速度会变快 10 倍吗?

不会。CI 总时长可能只快 10%-20%(阿姆达尔定律)。

编辑器体验的改变是质变:项目加载从几十秒到几秒,第一个错误从 17.5 秒到 1.3 秒,tsserver 崩溃率砍掉六成。这些数字改变的不是等待时间,而是你会不会在本地跑类型检查这个行为本身

Slack 那个案例最能说明问题:以前本地「几乎不可用」,大家把类型检查外包给 CI;现在几秒就能跑完,本地类型检查重新变成日常动作。工具的速度决定了工具会不会被用。


最后说点主观的。

TypeScript 7 这件事,技术上最值得学的不是「Go 比 JS 快」——这谁都知道。值得学的是**「逐行移植,不重构」这个反直觉的工程决策**,以及**「接受重复工作换取确定性」这个并行化权衡**。

前者告诉我们:大型迁移里,克制比聪明更重要。
后者告诉我们:并行不是把任务撒出去就完事,确定性往往比吞吐量更值钱。

至于什么时候升级——我的建议是:编辑器现在就开,CI 影子跑两周,emit 等到 7.1 API 落地后再说。

参考资料:

  • 官方公告:https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/
  • 原始规划(Anders Hejlsberg, 2025-03):https://devblogs.microsoft.com/typescript/typescript-native-port/
  • 源码与 CHANGES.md:https://github.com/microsoft/typescript-go

推荐文章

PHP设计模式:单例模式
2024-11-18 18:31:43 +0800 CST
16.6k+ 开源精准 IP 地址库
2024-11-17 23:14:40 +0800 CST
Python设计模式之工厂模式详解
2024-11-19 09:36:23 +0800 CST
程序员茄子在线接单