DEV Community

Cover image for I tried to compile TypeScript into a native binary with scriptc
Remo H. Jansen
Remo H. Jansen

Posted on

I tried to compile TypeScript into a native binary with scriptc

TL;DR: I tried to compile the TypeScript 6 compiler into a native binary with scriptc, and I failed — it got 90% of the way there and then hit an internal compiler error it couldn't get past, so there is no native tsc at the end of this story. But it's a failure worth having: I learned exactly where a week-old TypeScript-to-native compiler runs out of road, and I got some interesting numbers along the way.

So when scriptc was released to the public a few days ago, I knew exactly how my week was going to end.

If you haven't seen it yet, scriptc is a compiler that takes TypeScript — plain, ordinary TypeScript, no special dialect, no annotations — and turns it into a small, fast native binary. No Node.js. No V8. No JavaScript engine shipped alongside your code. A "hello world" comes out at around 320KB and starts in a few milliseconds. The pitch is bold: what compiles behaves byte-for-byte like Node. It does this with a three-tier model — most code lowers straight to native, anything too dynamic can opt into an embedded engine with a --dynamic flag, and the truly impossible fails at build time with a precise diagnostic instead of a surprise.

It's experimental. It's early. It's exactly the kind of thing I can't leave alone.

And almost immediately, a mischievous thought showed up: could I compile TypeScript itself?

Not a toy. Not a fibonacci function. The actual compiler — tsc — the thing that has type-checked basically every line of TypeScript I've ever written. If scriptc can turn that into a native binary, it can turn anything into a native binary. It felt like the ultimate stress test, and I wanted to see it either fly or fall over.

Why 6, and not 7?

Here's where the timing gets interesting.

If you've been following the TypeScript roadmap, you know the ground just shifted. TypeScript 6.0 shipped as the final JavaScript-based release of the compiler, and TypeScript 7 is the ground-up rewrite in Go — the "native" port the team has been building in the open. So we now live in a brief, strange window where two tscs exist side by side: the old one written in TypeScript, and the new one written in Go.

That window is the whole reason this experiment is fun.

Compiling TypeScript 7 with scriptc would make no sense — it's already native. It's already Go compiled down to a machine binary. There's nothing to prove there. But TypeScript 6 is different. TypeScript 6 is the last version of the compiler that is itself written in TypeScript. It's the perfect candidate: a real, enormous, battle-tested TypeScript program that I could feed to scriptc and ask, politely, to make native.

And it sets up a delicious comparison. On one side, TypeScript 6 — the JavaScript compiler, dragged into native code by scriptc. On the other, TypeScript 7 — the Go compiler, native by birth. Two paths to the same destination. I wanted to race them.

So I pinned my versions — typescript@6.0.3 and the Go build typescript@7 — wrote a handful of scripts so I could reproduce the whole thing on demand, and told scriptc to do its worst.

The climb

The first surprise was a pleasant one.

I pointed scriptc's coverage tool at the shipped tsc bundle — a single ~6MB file of generated JavaScript — bracing for a wall of red. Instead: 90% of statements compile statically. 51,045 out of 56,117. Nine out of every ten statements in the TypeScript compiler lower to native code without so much as a flag. That number genuinely made me sit up. The remaining 10% — the dynamic corners of a program that has to reflect on itself constantly — is exactly what --dynamic exists for.

So I asked for a --dynamic build. And scriptc fell over.

Not gracefully, either. A stack overflow — deep inside scriptc's own lowering pass, recursing until V8 ran out of room. Later I hit some internal compiler errors. Six of them, spread across three patterns. A sparse array initializer in createBracketsMap that scriptc couldn't represent. A return condition ? voidCall() : voidCall() in the binder that tripped a "ternary must not be void" check. And a cryptic one — "union u50: arm 0 is jsval" — that pointed at nothing in particular.

Two of those three, I could fix. I wrote a tiny, behavior-preserving codemod that rewrote the bracket map and unrolled that ternary into a plain if/else. Five of the six errors vanished. I was, briefly, convinced I was going to win.

The wall

The sixth error did not move.

"union u50: arm 0 is jsval" — reported at line 1, column 1, which is scriptc's way of telling you it isn't about any single line at all. It's a whole-program problem: somewhere in the compiler there's a union type where one of the arms is a fully dynamic value, and scriptc v0.0.17 simply doesn't know how to lay that out in native memory. There's no source edit that fixes it, because there's no source location to edit. And it's only the first such union.

That's the wall. And I want to be precise about what kind of wall it is: it's not a missing feature or a flag I forgot. It's the compiler's own youth. scriptc is at version 0.0.17. Asking it to swallow the entire TypeScript compiler — one of the most reflective, self-referential codebases in the JavaScript world — was always going to be the boss fight, not the tutorial.

So, no. I did not get a native tsc.

The benchmark I could run

But I'd built the whole racetrack, and I wasn't going to leave it empty.

If I couldn't put native-TypeScript-6 on the starting line, I could at least run the closest honest race: type-check the exact same project — the TypeScript compiler's own src/compiler source, --noEmit, same machine — with everything that could actually run it. And to be fair to scriptc, I also gave it two programs it could fully compile — a cold-start "hello" and a compute-heavy prime sieve — so it wasn't judged only by the one test it fails.

Here's everything in one place. Node (6.x) is TypeScript 6 running on V8; Go (7.x) is tsgo; scriptc.dev is the native binary:

Benchmark Node (6.x) Go (7.x) scriptc.dev
Type-check tsc's own source (--noEmit) 523 ms 107 ms ✗ won't compile
Cold start (console.log) 48.9 ms 3.6 ms
Compute (primes under 2,000,000) 0.24 s 2.33 s

Three things jump out.

On the workload that actually matters — type-checking real code — the Go compiler is roughly five times faster than TypeScript 6 on Node. On bigger projects the team reports closer to ten, and I believe them; here, Node's startup is eating a real slice of TS6's time. The rewrite is not hype. It's just fast. (tsgo is a compiler, not a general-purpose runtime, so it doesn't line up for the other two rows — hence the dashes.)

Then the good news for scriptc: its native binary starts in 3.6 ms against Node's 48.9 ms — about 13× faster, in a self-contained 350KB file with no runtime to boot. That's the dream, right there.

And then the humbling one: on a hot numeric loop, Node was 9.6× faster than scriptc's native output. V8's JIT has been tuned for a decade; scriptc's code generator has been public for a week. That gap is not a scandal.

So here's the honest shape of it. Even if scriptc had compiled tsc, it would have done so in --dynamic mode — the same JavaScript compiler, running on scriptc's embedded engine rather than natively lowered logic. Given what I saw, that binary would most likely have been slower than TypeScript 6 on Node, and nowhere near Go. The prize was never going to be speed. The prize would have been a single, dependency-free, instantly-starting binary — and that is a prize worth wanting.

Top comments (0)