Mehdi Akiki
Published on

Three TypeScript Compilers on the Same Code

Authors
  • Mehdi Akiki avatar
    Name
    Mehdi Akiki
    Twitter

Reference

My site is a Next.js app with 94 .ts and .tsx files, and every next build type checks all of it with the project's own tsc. The types disappear when the code runs, so only the type checker can use them.

On July 8, Microsoft released TypeScript 7.0, a port of the compiler to Go, "a 10x faster native port" with speedups "between 8x and 12x on full builds". TypeScript 6.0, in March, was meant to be the last release on the old JavaScript codebase. On October 20, Cloudflare introduced tsrs, a port to Rust by Max Schwenk that Cloudflare and VoidZero now develop with him, "up to 8x faster" than TypeScript 7.0, with about 70% less memory "when using the same number of type check workers".

So I wanted to know two things, on my own code: how much faster is each one, and is it still the same type checker?

Four compilers and one snapshot

I made a snapshot of the site at one commit, with the types that contentlayer and next typegen generate during the build.

I installed the compilers with npm, each in its own folder: [email protected], [email protected] and [email protected]. (My package.json still says 5.9.3.) The tsrs version looked strange to me, so I asked it:

Version 7.1.0-dev.20260930.4 (tsrs 0.18.0-ts7.1.0-dev.20260929, microsoft/TypeScript@b85298b6a81f)

So tsrs follows a 7.1 development version, commit b85298b6. The npm nightly with this version comes from the next commit, which only changes two CI files, so I added it as a fourth compiler: if tsrs and this nightly disagree, it is the port.

Every run uses the command line of next build in Next.js 16.3:

tsc -p tsconfig.json --noEmit --declarationMap false --emitDeclarationOnly false --tsBuildInfoFile FILE

None of them accepted my tsconfig

tsc 6 refused to check anything:

out/subject/tsconfig.json(22,5): error TS5101: Option 'baseUrl' is deprecated and will stop functioning in TypeScript 7.0. Specify compilerOption '"ignoreDeprecations": "6.0"' to silence this error.
  Visit https://aka.ms/ts6 for migration information.

My tsconfig has "baseUrl": ".". TypeScript 6.0 deprecated this option, and 7.0 removed it. TypeScript 7.0, the 7.1 nightly and tsrs printed the same five errors:

out/subject/tsconfig.json(22,5): error TS5102: Option 'baseUrl' has been removed. Please remove it from your configuration.
  Use '"paths": {"*": ["./*"]}' instead.
out/subject/tsconfig.json(25,9): error TS5090: Non-relative paths are not allowed. Did you forget a leading './'?
...

With "ignoreDeprecations": "6.0", tsc 6 was happy, and the three others still refused. So I removed baseUrl and added ./ to my four paths, like "@/components/*": ["components/*"]. Now all four compilers found 96 errors, and 51 of them were imports:

out/subject/app/(site)/about/page.tsx(6,33): error TS2307: Cannot find module 'app/seo' or its corresponding type declarations.
out/subject/app/(site)/blog/[...slug]/page.tsx(26,72): error TS2307: Cannot find module 'lib/publication.mjs' or its corresponding type declarations.
...

My code imports app/seo and lib/... without @/ and without ./. This works only because baseUrl is also a root for module lookup. The 6.0 announcement says that projects which really use baseUrl like this are "extremely rare". Well, mine is one of them. The fix is the catch-all mapping that the TS5102 message suggests:

"paths": {
  "@/components/*": ["./components/*"],
  "@/data/*": ["./data/*"],
  "@/layouts/*": ["./layouts/*"],
  "@/css/*": ["./css/*"],
  "contentlayer/generated": ["./.contentlayer/generated"],
  "*": ["./*"]
}

With it, all four compilers checked the site with zero errors, and I kept this config (in the snapshot only) for everything after.

What the compiler really reads

I wanted to know also the size of the work, so I grouped the files that tsc 6 lists with --listFiles:

group                           files     lines     MiB
site .ts and .tsx                  94     10786     0.4
site .d.ts and .d.mts              22       480     0.0
site .js, .mjs and .cjs            75     36994     1.4
site JSON                          12      2977     0.1
next typegen output                 4       571     0.0
contentlayer documents           1240    129497    42.4
contentlayer _index.json            3    129503    42.7
other contentlayer files            6      1426     0.2
TypeScript lib files               89     59040     2.8
declarations in node_modules     1203    205989     7.8
total                            2748    577263    98.0

This one surprised me. My own TypeScript is 10,786 lines, and the compiler reads 577,263. 87% of the bytes are JSON from contentlayer: one file for each page of content, and three _index.json files with all of them again. Removing them from include failed with TS6307, because two scripts import the generated index.mjs. (I did not try to keep only the three index files.)

Same answers?

So I made a second snapshot with 17 mistakes in 7 files: wrong types, misspelled names, a missing and an extra property, values that are possibly undefined, a forgotten await on Next.js params, a JSDoc error, etc.

All four compilers found the same 17 errors, at the same line and column, in the same order. And tsrs printed exactly the same 17 messages as the 7.1 nightly, byte for byte. I ran tsrs and TypeScript 7.0 five more times, and with several numbers of checkers, from single-threaded to 16 (a checker is a worker that type checks its own share of the files). The output never changed. Good, that is what I wanted to see.

Between tsc 6 and TypeScript 7, four messages were different. One line of the first, from tsc 6 and then TypeScript 7:

    Type '"blog"' is not assignable to type '"article" | "website" | "book" | "profile" | "music.song" | "music.album" | "music.playlist" | "music.radio_station" | "video.movie" | "video.episode" | "video.tv_show" | "video.other" | undefined'.
    Type '"blog"' is not assignable to type '"article" | "book" | "music.album" | "music.playlist" | "music.radio_station" | "music.song" | "profile" | "video.episode" | "video.movie" | "video.other" | "video.tv_show" | "website" | undefined'.

The same union, in another order. As the 6.0 announcement explains, tsc 6 sorts a union by the order in which it met each type, and TypeScript 7, which checks in parallel, by the content of the types. Here this gives alphabetical order. With the flag --stableTypeOrdering, tsc 6 printed three of the four messages exactly like TypeScript 7.

The fourth is a real difference, from tsc 6 and then from all three new compilers:

out/subject-errors/lib/rust-failure-atlas.ts(15,43): error TS2344: Type '{ title: string; slug: string; }' does not satisfy the constraint 'Publishable'.
  Property 'date' is missing in type '{ title: string; slug: string; }' but required in type 'Publishable'.
out/subject-errors/lib/rust-failure-atlas.ts(15,43): error TS2741: Property 'date' is missing in type '{ title: string; slug: string; }' but required in type 'Publishable'.

Same mistake, same position, but another error code. Both find the error, but a test that compares error codes will see a change.

How fast they were

I measured it like in my series about benchmarks that lie: one warm-up run, then 11 rounds where every variant runs once, in a rotating order, and I look at the median and the range. Each run starts without build info, that is the file where an incremental build keeps the results of the last run (I pass /dev/null as this file). From timing.txt, with some lines cut:

variant                wall median  wall min-max cpu median cpu/wall RSS median   RSS min-max
tsc6                       7295 ms   6484-8484     13670 ms     1.87   1486 MiB   1482-1494  
tsc7                       1505 ms   1432-1742      7000 ms     4.65   1085 MiB   1031-1093  
...
tsc7.1                     1530 ms   1318-1646      5953 ms     3.89    857 MiB    843-927   
tsrs                       1020 ms    936-1148      3046 ms     2.99    937 MiB    905-949   
...
tsrs-checkers4             1063 ms    868-1306      3102 ms     2.92    924 MiB    899-937   
tsc6+incremental           3086 ms   2547-3411      6516 ms     2.11   1025 MiB   1022-1031  
tsc7+incremental            711 ms    558-748       4108 ms     5.78    635 MiB    624-658   
tsrs+incremental            356 ms    314-489       1821 ms     5.11    643 MiB    621-655   

"wall" is the time I wait, "cpu" the CPU time of all threads together (so "cpu/wall" says how many cores were busy on average), and "RSS" the most memory used at one moment.

TypeScript 7 is about 4.8 times faster than tsc 6 here (4.5 in another session): a full check goes from 7.3 to 1.5 seconds. Very good, but not 10x. With 4 checkers, like TypeScript 7, tsrs is only 1.4 times faster, not 8x, and uses 15% less memory, not 70%.

Why so far from the claims? My first idea was the size of my project, but the sources disagree. In the TypeScript 7 table, tldraw needs 1.46 seconds with TypeScript 7, like my site, and it got 7.7x. On the tsrs benchmark page, webpack needs 1.46 seconds with TypeScript 7.0.2 and 0.29 with tsrs and 4 checkers, on a 16-vCPU server. So the reason is in my project or on my laptop. Part of it was the laptop, as I found later.

The load was under 2.0 at the start of each timing session, and no run of a separate session had a major page fault (faults.txt): nothing came back from the full swap.

Threads on a laptop with two kinds of cores

TypeScript 7 uses 4 checkers by default, and the tsrs rule gives 4 for a project of my size too: the tsrs and tsrs-checkers4 lines below are almost the same. My laptop has 4 performance cores (two threads each) and 8 slower efficiency cores. With --checkers 1, the parsing still runs in parallel, and --singleThreaded turns off all of it (timing-threads.txt):

variant                wall median  wall min-max cpu median cpu/wall RSS median   RSS min-max
tsc7-single                2296 ms   1991-2715      3163 ms     1.38    790 MiB    782-812   
tsc7-checkers1             1876 ms   1646-2072      5662 ms     3.02    843 MiB    814-859   
tsc7-checkers2             1714 ms   1634-1937      6395 ms     3.73    929 MiB    909-984   
tsc7                       1593 ms   1458-1669      6985 ms     4.39   1071 MiB   1028-1100  
tsc7-checkers8             1513 ms   1413-1661      9041 ms     5.97   1259 MiB   1222-1289  
tsc7-checkers16            1571 ms   1432-1840     13036 ms     8.30   1657 MiB   1638-1712  
tsrs-single                 827 ms    620-1339       916 ms     1.11    616 MiB    604-624   
tsrs-checkers1              830 ms    702-1064      2242 ms     2.70    729 MiB    695-760   
tsrs-checkers2              882 ms    708-1092      2554 ms     2.89    790 MiB    781-808   
tsrs-checkers4             1063 ms   1027-1303      3048 ms     2.87    921 MiB    912-926   
tsrs-checkers8             1458 ms   1264-1831      3629 ms     2.49   1061 MiB   1043-1066  
tsrs-checkers16            1203 ms   1036-1669      3679 ms     3.06   1025 MiB   1015-1038  
tsrs                       1057 ms    969-1321      3040 ms     2.88    943 MiB    933-951   

TypeScript 7 gets faster up to 4 or 8 checkers, then it stops, and with 16 checkers it needs 1,657 MiB instead of 1,071. tsrs did not like more threads at all: single-threaded and with 1 checker it was the fastest (827 and 830 ms), but with a very wide range, from 620 to 1,339 ms. Strange.

So I pinned the runs with taskset to CPUs 0-7 (performance) or 8-15 (efficiency), with 15 rounds (timing-cores.txt):

variant                wall median  wall min-max cpu median cpu/wall RSS median   RSS min-max
tsc6                       7204 ms   6438-7994     13558 ms     1.88   1487 MiB   1484-1491  
tsc6-pcores                6590 ms   6074-7236     12461 ms     1.89   1489 MiB   1483-1493  
tsc7                       1594 ms   1465-1750      7148 ms     4.48   1067 MiB   1023-1087  
tsc7-pcores                1490 ms   1410-1719      5366 ms     3.60   1076 MiB   1044-1086  
tsc7-ecores                2123 ms   1938-2219      7103 ms     3.35   1073 MiB   1037-1096  
tsrs-checkers4             1135 ms    946-1418      3120 ms     2.75    920 MiB    908-939   
tsrs-checkers4-pcores       976 ms    785-1051      2279 ms     2.34    872 MiB    846-891   
tsrs-checkers4-ecores      1337 ms   1263-1427      2954 ms     2.21    870 MiB    843-895   
tsrs-single                 798 ms    718-899        915 ms     1.15    614 MiB    602-624   
tsrs-single-pcores          689 ms    660-779        737 ms     1.07    599 MiB    591-611   
tsrs-single-ecores         1046 ms   1015-1146      1118 ms     1.07    601 MiB    594-619   

Pinned, single-threaded tsrs is stable: 689 ms on a performance core, 1,046 ms on an efficiency core. But even the unpinned runs were calmer in this session (718 to 899 ms), so the cores do not explain all of the first range. And the 8 efficiency cores did not make any compiler faster: the medians were even 7 to 14% lower without them (the ranges overlap).

The TypeScript 7 announcement says that the checkers "may end up duplicating some common work, but given the same input files, they will always divide them identically". So the files are split before the work starts, and the check ends with the last checker. More checkers means more repeated work and memory, and more chances that one runs on a slow core. TypeScript 7 still won (2.3 seconds single-threaded, 1.6 with 4 checkers). tsrs did not, and the kernel shows one more reason.

What the kernel was doing

tsrs spent about 40% of its CPU time in the kernel, TypeScript 7 about 18%. So I read the counters of /proc/vmstat before and after each run (vmstat.txt):

variant                       sys      compact_stall       compact_fail allocstall_movable    thp_fault_alloc thp_fault_fallback
tsc7-thp-default          1411 ms                  0                  0                  0                148                301
tsc7-thp-off               894 ms                  0                  0                  0                  6                  0
tsrs-thp-default          1482 ms                680                680                241                179                328
tsrs-thp-off               713 ms                  0                  0                  0                  3                  0
tsrs-single-thp-default    420 ms                266                266                 91                179                120
tsrs-single-thp-off        208 ms                  0                  0                  0                  9                  0

(The counters are system-wide.) compact_stall counts how many times a program waited while the kernel moved memory around to build a huge page, that is one 2 MiB page instead of 512 small ones, and allocstall_movable the waits while it freed memory. tsrs asks for huge pages with madvise (61 times in one run, TypeScript 7 once). My laptop has little free memory, and during a tsrs run the kernel tried 680 times and failed every time (compact_fail). TypeScript 7 caused no stall, but still got 148 huge pages: with my kernel's defrag setting, madvise, only memory marked with madvise waits (kernel documentation).

tsrs builds its allocator so that mimalloc turns huge pages off for the whole process at start (prim.c). Then tsrs turns them back on for its arena (allocator.rs): its authors measured it 3% to 25% slower without them on their servers (notes). It skips this when MIMALLOC_ALLOW_THP is set, so MIMALLOC_ALLOW_THP=0 keeps them off (timing-thp.txt):

variant                wall median  wall min-max cpu median cpu/wall RSS median   RSS min-max
tsc7-thp-default           1647 ms   1563-1737      7094 ms     4.31   1073 MiB   1014-1088  
tsc7-thp-off               1598 ms   1421-1636      6363 ms     3.98   1070 MiB   1009-1081  
tsrs-thp-default           1134 ms   1007-1316      3228 ms     2.85    869 MiB    854-879   
tsrs-thp-off                795 ms    715-945       2557 ms     3.22    740 MiB    739-740   
tsrs-single-thp-default      997 ms    858-1105      1112 ms     1.12    603 MiB    596-605   
tsrs-single-thp-off         790 ms    687-947        829 ms     1.05    525 MiB    524-527   

Without huge pages, tsrs took 795 ms instead of 1,134, with 740 MiB instead of 869, and single-threaded was not faster any more. That is twice as fast as TypeScript 7 in the same session. Still not 8x, but now I see where 30% of the time went. This is my laptop, not a rule: on the tsrs servers, huge pages made it faster. Here the swap was full, the 680 tries failed, and tsrs waited for each one.

What I would use

For my build, TypeScript 7 is the easy choice. It found the same errors as tsc 6, and the Next.js documentation says that installing typescript@^7 is enough. The real work is the tsconfig migration (Next.js reads paths too, so a full build must pass first). In the editor I would wait: my tsconfig loads the next plugin, and the 7.0 post recommends TypeScript 7 "in scenarios where language server plugins are not required".

tsrs was the fastest, and it gave exactly the answers of the nightly it follows. But it is an experimental 0.x release, and its README warns: "Known checker-order issues can make errors appear or disappear between runs". It also follows 7.1, not 7.0, and the two already differ: in one of my messages, and on 8 of the 17 projects of the tsrs benchmark page. So I would use it as a fast first check, and keep TypeScript 7 as the gate in CI.

tsc 6 stays installed too: TypeScript 7.0 "does not ship with an API", and typescript-eslint still needs one, through @typescript/typescript6.

On your own project, compare the answers first:

npx -p typescript@6 tsc -p . --noEmit --pretty false > ts6.txt
npx -p typescript@7 tsc -p . --noEmit --pretty false > ts7.txt
npx -p tsrs tsrs -p . --noEmit --pretty false > tsrs.txt
diff ts6.txt ts7.txt
diff ts7.txt tsrs.txt

Then time them with interleaved runs, without build info. If one spends much time in the kernel, look at compact_stall in /proc/vmstat first. And on a laptop with two kinds of cores, try --singleThreaded and taskset: the default is not always the fastest.

Sources