tsgo 与 tsrs 使用匹配的 TypeScript 源版本运行四组 TypeScript 合成样例,比较诊断和退出码;四组结果一致。
图:tsgo 与 tsrs 在四组单文件合成样例中的诊断对照。四组输出一致;这不是项目级兼容性或性能基准。

Cloudflare 介绍 tsrs 时说,相对 TypeScript 7.0,最高快 8 倍、内存约少 70%。我翻到 tsrs 固定提交的历史基准表,看到 mui-docs 有 16.87 倍、vscode 默认设置有 13.55 倍,第一反应就是:这跟公告里的 8 倍怎么对不上?

我没重跑那套大型项目性能基准。这次做的是另一件小事:把 tsrs 和 tsgo(TypeScript 的 Go 实现)固定到对应版本,放进 GitHub 托管的临时隔离环境,拿四个 TypeScript 小样例比诊断。四组都对上了;这让我愿意继续往下看,但还不能据此把生产 CI 的类型检查器换掉。

官方跑分里的 checker 数为什么重要

Cloudflare 的公告把早期结果概括为最高快 8 倍、内存约少 70%。固定提交的 tsrs 历史基准报告里,列出的倍率是这样的:

上游历史基准tsgotsrschecker 数上游报告倍率
mui-docs,默认设置12.66 秒0.75 秒4 vs 1616.87 倍
mui-docs,双方 --checkers 412.69 秒1.07 秒4 vs 411.81 倍
mui-docs,双方 --checkers 1612.36 秒0.77 秒16 vs 1616.07 倍
vscode,默认设置11.10 秒0.82 秒4 vs 1613.55 倍
vscode,双方 --checkers 166.73 秒0.81 秒16 vs 168.32 倍

默认设置下,tsgo 用 4 个 checker,tsrs 用 16 个;但把 checker 数对齐后,表里的倍率仍然超过 8 倍。这里有个容易忽略的区别:Cloudflare 公告讲的是早期基准的概括,仓库里的 RESULTS.md 则是另一份固定版本、固定环境的历史快照。不能把前者当成这张表的上限,也不能把后者的最高倍率当成自己的项目一定能拿到的成绩。

为什么差这么多?我不想盯着表格猜一个漂亮的原因。能确认的是,这份快照跑在 Depot CI 的 depot-ubuntu-24.04-16 上,机器是 16 vCPU、63 GB RAM、Linux x86_64;时间和峰值内存取交错运行的中位数。tsgo 基线是 typescript@7.0.2,tsrs 移植的是更新的 TypeScript 7.1-dev 提交,而且使用 PGO 优化的发布构建。checker 数对齐了,源码版本和构建方式却没完全对齐。

内存数字也会跟着项目和设置变。mui-docs 默认设置下,报告写的是少 4.61 倍;两边都开 16 个 checker 时则是少 8.23 倍。公告里的“约少 70%”可以当作早期结果的概括,不能当成每个项目都会兑现的固定比例。这部分我只查了上游报告,没有重跑大型项目基准。

四组诊断测试实际覆盖了什么

我固定使用 tsrs@0.18.0-ts7.1.0-dev.20260929,对照 typescript@7.1.0-dev.20260930.4 的 Go 实现。两者对应同一个 TypeScript 源码提交:b85298b6a81f772d080b0455de0ca9d744cd6fd6。

装依赖时先踩了个很具体的坑:我把 TypeScript 源码仓库里的 @typescript/typescript workspace 名称当成 npm 上能直接安装的包,结果拿到 E404,编译器根本没启动。公开发布的根包叫 typescript;这个版本的命令行入口仍是 tsc,Linux x64 的原生 Go 编译器由 @typescript/typescript-linux-x64 提供。把包名改对,才跑到真正的编译步骤。

四个夹具共用同一份 tsconfig.json。我把 types 显式设成空数组,避免测试环境自动带入全局 @types 包;skipLibCheck: false 则让声明文件检查保持开启。这样至少能把这轮对照实际用的配置摊出来,而不是只给一个“严格模式”的口头描述。

{
  "compilerOptions": {
    "strict": true,
    "target": "es2022",
    "module": "esnext",
    "noEmit": true,
    "skipLibCheck": false,
    "types": []
  },
  "include": ["src/*.ts"]
}

每个夹具目录里只有一个 src/test.ts,内容如下:

夹具目录src/test.ts 的完整内容实测结果(tsgo / tsrs)
validexport const answer: number = 42;0 个诊断,退出码 0 / 0
type-errorexport const answer: number = "wrong";TS2322,退出码 2 / 2
module-missingimport {missing} from "./unavailable.js"; export const answer = missing;TS2307,退出码 2 / 2
generic-constrainttype OnlyText<T extends string> = {value:T}; export type Bad = OnlyText<number>;TS2344,退出码 2 / 2

两边都用了 --noEmit --pretty false --checkers 4。我逐项比了文件、行列位置、错误码、消息、顺序和退出码,四组都对上了。不过每个夹具就一个源文件,--checkers 4 在这里主要是固定参数,并没有真正压测四路并行。

为什么错误数量相同仍然不够

光看错误总数,很容易高兴早了。tsrs 固定版本的兼容性说明里,错误基线对上了 13,458 / 13,462 项,.types 和 .symbols 基线则有 12,779 项全部匹配。

drizzle-orm 就是个现成的反例。上游在双方都使用 16 个 checker 的报告里记下了 10,846 个错误,但两边的诊断位置不同,并直接把它标成 tsrs bug。这个例子我没有自己复现,是从固定版本的报告里读到的。对 CI 和代码评审来说,错误落在哪个文件、哪一行,比错误总数看着整齐更重要。

兼容表里还有几个实际会碰到的限制:这个版本的 --init 和 --showConfig 还不支持,--locale 也不能翻译诊断消息;.astro、.vue、.svelte 的 content mapper 能用于 CLI/build,但语言服务器还不支持。预构建包也没有覆盖 Windows 和 Intel macOS。我这轮只在 GitHub 托管的隔离 Linux 环境里跑了 CLI,没测试这些平台、IDE 插件或 LSP,所以如果你的日常工作流依赖 astro check 或编辑器实时诊断,先对照这个版本的兼容性表再决定要不要折腾。(如果你正好在排查 Astro 升级后的类型检查问题,我之前也整理过一篇兼容排查笔记。)

在自己的项目里对照,什么结果才够用

建议先在一次性 worktree 或独立分支里试,别直接改日常工作区。这一步就是为了试完能随时丢掉,不影响手头的项目。

下面这段脚本防的是一种很容易漏掉的情况:两边退出码一样,但具体诊断不一样。这里的 set +e 不是笔误——有错误的夹具本来就会让编译器返回非零;先让两边都跑完并记下退出码,才能继续比较输出。脚本里的 tsc 是固定 TypeScript 7.1-dev 包提供的 Go 编译器入口,/tmp/tsgo.out 只是日志文件名。

pnpm add -D --save-exact tsrs@0.18.0-ts7.1.0-dev.20260929 typescript@7.1.0-dev.20260930.4

set +e
pnpm exec tsc -p . --noEmit --pretty false --checkers 4 > /tmp/tsgo.out 2>&1
tsgo_status=$?
pnpm exec tsrs -p . --noEmit --pretty false --checkers 4 > /tmp/tsrs.out 2>&1
tsrs_status=$?
diff -u /tmp/tsgo.out /tmp/tsrs.out
diff_status=$?
set -e
printf 'tsgo_exit=%s tsrs_exit=%s diff_exit=%s\n' "$tsgo_status" "$tsrs_status" "$diff_status"
if [ "$tsgo_status" -ne "$tsrs_status" ] || [ "$diff_status" -ne 0 ]; then
  echo 'DIAGNOSTIC_DIFF_OR_EXIT_MISMATCH: inspect /tmp/tsgo.out and /tmp/tsrs.out'
  exit 1
fi

如果第一次对照不一致,先把原始输出留好,再单独试 --checkerAssignment go(采用 tsgo 的文件分配与 checker 历史)或 --noLazyMembers(关闭 tsrs 的 lazy-member 优化)。不要一开始就打开这些开关,不然你会看不到默认行为到底差在哪。具体参数见使用说明和默认值差异表。

上面只对诊断,不测耗时和内存。我自己这次没把性能测量跑完;如果你想先摸一下手头项目,可以用下面的命令记一次运行时间和峰值 RSS。Linux 用 GNU time;macOS 先装 gnu-time,再把第一行改成 TIME_BIN=gtime。

TIME_BIN=/usr/bin/time

set +e
"$TIME_BIN" -f 'elapsed_s=%e max_rss=%M' -o /tmp/tsgo-bench.metrics \
  pnpm exec tsc -p . --noEmit --pretty false --checkers 4 \
  > /tmp/tsgo-bench.out 2>&1
tsgo_status=$?
"$TIME_BIN" -f 'elapsed_s=%e max_rss=%M' -o /tmp/tsrs-bench.metrics \
  pnpm exec tsrs -p . --noEmit --pretty false --checkers 4 \
  > /tmp/tsrs-bench.out 2>&1
tsrs_status=$?
set -e

printf 'tsgo_exit=%s ' "$tsgo_status"; cat /tmp/tsgo-bench.metrics
printf 'tsrs_exit=%s ' "$tsrs_status"; cat /tmp/tsrs-bench.metrics

这两条命令只够记下一对结果,机器后台稍微忙一点就可能抖。真要比较,我会在同一台 runner 上交错跑至少 5 轮,锁住依赖、版本、tsconfig 和 checker 数,再看中位耗时和峰值 RSS。冷启动和热缓存也分开记;我没去清理操作系统 page cache 那一层,所以不会把它说成完整的系统级冷缓存测试。GNU time 的 %M 通常按 KiB 记峰值 RSS,跨平台比较前先确认单位。

我会先让 tsrs 在真实项目的 CI 里当一条额外任务陪跑,原有的 tsc 和框架检查继续做必需门禁。等诊断差异解释清楚、性能测量有了稳定结果,再让它跟完一轮真实 PR 和发布周期,并确认回滚路径能走通,才值得讨论要不要替换。四个小样例让我确认的只是起点:在这几种输入上,两边给出了相同诊断。

实测日期:2026 年 10 月 22 日