scriptc: TypeScript to Native Binaries, No Node Required

12 min readBy Nathan House

A TypeScript file that prints a Fibonacci number compiled to a 375KB binary on my Mac last week. It ran in about twelve milliseconds. It had no Node.js inside it, no V8, no JavaScript engine of any kind. otool -L showed it linking exactly one library, the macOS C runtime.

That is what scriptc does. Vercel Labs pushed it to GitHub eighteen days ago. If you write TypeScript and you've ever wanted to hand someone a single file instead of a runtime, a node_modules folder and an install guide, this is aimed squarely at you.

We spent a day putting it through its paces: not reading the README, but compiling our own production scripts with it, cross-compiling to Linux, and running the result on a live Ubuntu server. Some of it is better than advertised. One part of it broke our code in a way its own coverage report gave no warning about. And there is a question about whether the project will exist in a year that you should weigh before you build anything on it.

TL;DR if you've only got 30 seconds

375KB, no engine. Native machine code with no Node, no V8. bun build --compile gave us 60MB for the same file.

Ten times less memory. 8.3MB peak against Node's 86.4MB on identical work, with byte-identical output.

9 of our 455 scripts compiled. We swept a real codebase. The wall is dependencies, not the compiler.

One author, eighteen days old. 466 of 468 commits from one developer, and the same lab's last language project went quiet after six weeks.

What scriptc Actually Is

The scriptc logo, a white triangle on a dark rounded square beside the scriptc wordmark

scriptc compiles TypeScript to native machine code. Not bundled TypeScript, not TypeScript with a runtime stapled to it, but actual machine code, the same class of artefact a Go or Rust toolchain produces.

The pitch is "zero-runtime TypeScript." Your code is parsed and type-checked by the real tsc, lowered to a typed intermediate representation, emitted as C, and compiled by clang. What comes out the other end is a normal executable.

The numbers, as of publication:

Repository. vercel-labs/scriptc, Apache-2.0 licensed, created 22 July 2026.

Traction. Over 3,000 stars in its first eighteen days.

Version. 0.0.22, and running scriptc --help describes itself as experimental. The README does not say that. The CLI does.

Install is one command, and you need clang, which you'll already have if you've got Xcode Command Line Tools. macOS arm64 is the primary target.

Install
npm install -g scriptc

The Three Tiers, and Why "Rejected" Is a Feature

Most tools that promise to compile a dynamic language quietly fall back to shipping an interpreter when they hit something hard. scriptc's central design decision is that it refuses to do this silently.

Every construct in your program lands in one of three tiers:

1

Compiled statically. Native code, no engine. This is the default and, unless you opt out, the only mode. Classes, closures, generics, async/await, exceptions, regular expressions, plus a large slice of Node's API surface: fs, path, process, child_process, crypto, net, http, https, tls. Real servers compile.

2

Runs dynamically. Opt in with --dynamic and scriptc embeds quickjs-ng, a small JavaScript engine of about 620KB, to execute what cannot be static, meaning npm dependencies' shipped JavaScript and any-typed code. Values crossing back into static code get validated at runtime.

3

Rejected. Everything else fails the build with a specific error code and a code frame, usually with a rewrite hint. We counted 86 distinct SC error codes in the source.

The three scriptc tiers: compiled statically with no engine, runs dynamically with an embedded 620KB engine, or rejected at compile time with an error code

That third tier is the part I like. Ask it to compile a loose == and you get SC1040: loose equality (== and !=) is not supported yet. Use a bare any without --dynamic and you get SC2011, along with a hint telling you to either opt into the engine or switch to unknown with a checked cast. Nothing is quietly miscompiled into something that behaves differently at three in the morning.

You can ask before you commit. scriptc coverage reports what percentage of your statements compile statically and names every blocker. Hold that thought, because we are coming back to it.

Check before you commit
scriptc coverage yourfile.ts

What We Measured

We wrote a ten-construct test covering classes with private fields, closures, mutual recursion, async/await, Map, sort, float precision, JSON and regex. We ran it under Node, ran the compiled binary, and diffed the output.

Byte-identical. Zero difference.

That's not luck. Every change to scriptc runs a corpus of programs under both Node and as native binaries, and stdout, stderr and exit codes must match exactly. The README claims "800+ tests." We cloned the repo and counted 1,075 files in the corpus. For once, the marketing undersells.

Speed and size, same program, same machine:

Nodescriptc
Startup (median of 15 runs)67.7ms12.5ms
What you ship~90MB runtime install375KB binary
Peak memory (50,000-entry Map)86.4MB8.3MB
Binary size comparison: scriptc 375KB with no engine, bun build --compile 60MB embedding Bun, Node.js requiring a roughly 90MB runtime install

Ten times less memory, identical output. If you've ever run a small Node process in a container and watched it reserve eighty megabytes to do almost nothing, that number is the point.

One more, for anyone comparing against the other contender in this space. Perry, a rival TypeScript-to-native compiler documented at perryts.com, has a bug reported in review coverage in which a self-recursive function whose recursive call sits inside a conditional returns zero. We wrote that exact shape and compiled it with scriptc: correct answer, 75025. We did not test Perry ourselves. Whatever else is true, scriptc is the more trustworthy of the two right now.

The Checked Cast, Which Is the Bit Security People Should Care About

This is the feature I didn't expect to be the most interesting one.

TypeScript's as is a promise you make to the compiler and nobody ever checks. Write this in Node:

cast.ts
interface Config { port: number; host: string; }

const bad = '{"port":"8080","host":"localhost"}';  // port is a string
const cfg = JSON.parse(bad) as Config;

console.log(cfg.port + 1);

Node prints 80801. It concatenated a string to a number, because at runtime port was never a number and nothing ever verified the claim. Your types said one thing, your data said another, and the program carried on with garbage.

scriptc compiles the same file and throws instead:

scriptc, same file
expected number at $.port, got string

In plain English

scriptc inserts a runtime validation at the cast and throws a catchable error naming the exact path that lied. For anyone who has chased a type-confusion bug through a service that trusted its own JSON, that is a whole class of defect turned into an error message at the boundary. If you run these checks by hand today, our secure code review checklist covers the wider method.

The same bad JSON in Node and scriptc: Node prints 80801 after silently concatenating a string, scriptc throws an error naming the offending field

It's worth the price of admission on its own.

We Tried It on 455 Real Scripts

Reviews of compilers are cheap. We have 455 TypeScript utilities in our own working system, so we pointed scriptc at all of them. Building tools at that volume is the agentic coding shift in practice: the writing stopped being the bottleneck a while ago.

The first wall isn't scriptc's fault, and it's worth naming because it will apply to a lot of readers. 137 of our 455 scripts, 30% of them, use Bun's APIs: Bun.spawn, Bun.file, Bun.argv. scriptc targets Node semantics and does not know what Bun is, so those do not even type-check. If you've standardised on Bun, as we have, that decision is exactly what locks you out.

We swept all 318 Bun-free scripts:

ResultCount
Analysed successfully158
Blocked by pre-existing type errors160
Hit 100% static coverage9
Compiled to a native binary9 of 9
Ran correctly first time8 of 9
Sweep results across 455 scripts: 137 disqualified for using Bun APIs, 318 swept, 160 blocked by pre-existing type errors, 9 hit full static coverage, 8 ran correctly first time

That 160 deserves a note. Those are our type errors, not scriptc's limitation. bun run executes TypeScript without type-checking it, so those errors had been sitting in our scripts invisibly. Pointing a strict compiler at your codebase is a free audit, and ours came back with homework.

Nine scripts compiled to working native binaries, from 375KB up to 1.5MB: a vault gate, a sales-data cleaner, a job-listing normaliser, a licence auditor, a mailing-list source mapper, a search client, a YouTube uploader, a YouTube scheduler, and our Jellyfin CLI. The licence auditor did real work on first run.

The one that broke

jellyfin.ts, our media-server CLI, reported 100% static coverage. It compiled without a single warning. Then it aborted:

The failure
scriptc: RangeError: array index 0 out of bounds (length 0)
exit 134

The culprit is line 45:

jellyfin.ts (before)
const cmd = args[0] ?? "help";
The same line of code in Node and scriptc: Node prints help, scriptc raises a RangeError and exits 134 because the array read traps before the nullish coalescing runs

That idiom is everywhere in command-line code. Under Node and Bun, reading past the end of an array gives you undefined, the ?? catches it, and you get your default. scriptc's arrays are dense, with no holes and no undefined elements, so the out-of-bounds read itself traps before ?? ever runs. And per its own documentation, that trap is not catchable. The process dies.

The fix is one line:

jellyfin.ts (after)
const cmd = args.length > 0 ? args[0] : "help";

Rebuilt, it produced byte-identical output to Bun and exited 0.

The lesson worth taking away

100% static coverage means the program compiles, not that it works. The coverage report is a compile-time instrument and this is a runtime divergence, so it cannot see it. Anyone porting a CLI will hit this, because argv[0] ?? default is how everybody writes CLIs.

For what it is worth, companies-house.ts sat at 98% and failed the build on a single Number.parseInt. The gap between 98% and shippable is thinner than the percentage suggests.

Mac, Linux and Windows From One Machine

Cross-compilation is not a flag. There is no --target option; I tried, and it errors. It runs on zig, which you install separately, plus two environment variables:

Cross-compiling from macOS
SCRIPTC_CC=zigcc SCRIPTC_TARGET=x86_64-linux-gnu.2.36 scriptc build app.ts -o app-linux
SCRIPTC_CC=zigcc SCRIPTC_TARGET=aarch64-linux-gnu.2.36 scriptc build app.ts -o app-arm
SCRIPTC_CC=zigcc SCRIPTC_TARGET=x86_64-windows-gnu scriptc build app.ts -o app.exe

All three produced binaries from the same Mac: 1.3MB ELF for Linux x86_64, 1.2MB for Linux arm64, 562KB PE32+ for Windows.

Platform support matrix showing binaries build for macOS, Linux and Windows, but only the macOS and Linux binaries were executed and only those had networking verified

Headers prove nothing, so we copied the Linux binary to one of our Ubuntu 24.04 servers and ran it. Correct output, exit 0. Then a harder test. A program doing an HTTPS fetch and spawning a child process, cross-compiled from macOS and executed on that same Linux box: http status: 200, child_process-ok. The HTTPS path we tested worked, from a binary built on a machine running a different operating system.

Three caveats. Linux binaries are not fully static; they link libc and libm against a glibc floor you choose in the triple, which we tested against Ubuntu 24.04 and glibc 2.39; a musl-based distribution such as Alpine would need a different target. --dynamic binaries cannot cross-compile at all, because the engine archive is host-native, so cross-compilation only serves fully static programs. And Windows: we built the .exe but never ran it, because we have no Windows machine here. The platform-support page at scriptc.dev says the socket and server stack and child_process are not ported to Windows yet, and notably our networking test built without any warning at all for that target. Treat a Windows binary that touches the network as untested.

Where It Falls Short Today

The dense-array trap above is the sharpest edge, but it isn't alone.

.replace() and .replaceAll() on strings do not compile statically. They fail with SC2012 and push you to --dynamic, which works and stays byte-identical to Node but takes a 375KB binary to 1.2MB. String replacement isn't an exotic operation. In practice a lot of real code lands in the dynamic tier and forfeits both the size win and cross-compilation.

Beyond that, the documented divergences are worth reading before you port anything: runtime traps abort rather than throw, records flowing into narrower shapes are copied rather than aliased, Object.keys reports declaration order rather than insertion order, and memory is reference-counted rather than garbage-collected. None of these are hidden, and the project publishes them plainly, which is more than most do, but they are real behavioural differences, not footnotes.

And it's version 0.0.22, self-described as experimental, with 37 open issues.

Will It Still Be Here in a Year?

This is the part that should shape what you do with it.

We looked at the commit history through GitHub's API. As of 9 August 2026, 466 of the project's 468 commits come from a single developer. Simon Willison appears in the contributor list, which lends the project visible credibility, but his contribution is a single commit to the README. The code is essentially one developer's work, moving fast.

There's a precedent, and it's close to home. In May, the same Vercel Labs shipped zerolang, "The Programming Language for Agents." It drew 5,269 stars and roughly 1,200 commits. Its last commit was 28 June. It has 131 open issues and has not been archived. It's simply not being worked on. Same lab, same shape, same solo-author velocity curve, six weeks dead.

zerolang and scriptc compared: both Vercel Labs language projects driven by the same solo author, with zerolang last committed on 28 June 2026

The Hacker News thread on scriptc ran to 286 points and 157 comments, and the scepticism was blunt: "Vercel sloping its way to clout again." The counter-argument in that same thread is fair, though. Agent-browser and portless came out of the same lab and are still going. Experiments in public are a legitimate way to work.

I'm not going to tell you it's doomed, because I don't know, and the engineering quality here is genuinely high. The 1,075-file differential corpus and the AddressSanitizer lane are not the work of someone chasing a press cycle. But "one author, eighteen days old, and the lab's last language project went quiet after six weeks" is the risk you are accepting. Don't put it under something that has to work in production next quarter.

What This Looks Like as It Matures

Take the failure modes seriously and there's still a real thing here.

Today, scriptc suits a narrow shape: a self-contained tool, no npm dependencies, no Bun APIs, careful about array indexing. Nine of our 455 scripts qualified, and every one of them is a leaf script with no dependencies. That's 2%, and I'm not going to dress it up.

But the direction is worth watching. The reason we run our tooling on Bun is that we all have Bun installed. The moment you want to hand a tool to somebody who does not, whether a client, a student, or a colleague on a locked-down machine, the runtime stops being invisible and becomes the whole problem. "Install Node, then run npm install, then run this" is where most internal tools go to die.

A 375KB file that just runs is a different proposition. Ten times less memory matters when you are running many small processes. A twelve-millisecond start matters when something invokes your tool in a loop. And the checked cast quietly removes a bug class that has burned plenty of production services.

The gap between that future and today is dependencies. When --npm-static matures past experimental and the standard library surface fills in, the qualifying set gets much larger than nine.

The reason I'm watching this closely is that I build a lot of tools. Working the way I do now, directing AI across a whole delivery pipeline rather than writing every line myself, the bottleneck stopped being writing the tool a long time ago. It's handing it to someone. Most of the internal tools I've built run on my machine and nowhere else, because giving one to anybody means talking them through installing a runtime first. A single file they can just run, on whatever operating system they happen to have, is what makes those tools worth building for other people rather than only for me. That is the same distribution problem agentic engineering runs into once the building gets cheap.

The one thing to take away

Watch it, test it, don't build on it yet. scriptc can't do this for my real tooling today. The moment a script touches an npm package or Bun's APIs, the advantage evaporates, and that covers almost everything I actually use. But that's the gap I'm watching close.

Frequently Asked Questions

What is scriptc?

A compiler from Vercel Labs that turns TypeScript into native executables with no JavaScript engine inside them by default. The repository was created on 22 July 2026, it is Apache-2.0 licensed, and it is currently at version 0.0.22.

How is scriptc different from bun build --compile or deno compile?

Those embed their whole runtime in the output. We compiled the same file with bun build --compile and got a 60MB binary; scriptc produced 375KB. The trade-off is that Bun and Deno will run essentially any valid program, while scriptc rejects at compile time whatever it cannot compile.

Does scriptc work on Linux and Windows?

It cross-compiles to both from macOS using zig. We built and successfully ran a Linux binary on Ubuntu 24.04, including HTTPS and child processes. We built a Windows binary but did not run it; the project's own documentation says the socket, server and child_process surfaces are not ported to Windows yet.

Can I use my npm dependencies with scriptc?

Only with --dynamic, which embeds a small JavaScript engine of about 620KB to run the package's shipped JavaScript. Static builds are the default and never include the engine. Compiling npm packages statically is possible via --npm-static, which the project labels experimental.

Is scriptc ready for production?

No. It is version 0.0.22, its own CLI calls itself experimental, and 466 of its 468 commits come from a single developer. It is worth testing and worth watching; it is not worth putting under something that has to work next quarter.

What is the biggest gotcha when porting existing code to scriptc?

Arrays are dense, so reading past the end aborts the process instead of returning undefined. The common CLI idiom const cmd = args[0] ?? 'help' traps before the ?? runs. Rewrite it as an explicit length check. This bit us in a script that reported 100% static coverage.

About the Author

Nathan House

Nathan House, Founder & CEO of StationX

Nathan House has 30 years of hands-on cybersecurity experience and is Cambridge-educated, holding CISSP, CISA, CISM, OSCP, CEH, and SABSA. He founded StationX in 1999 — one of the UK’s first cybersecurity companies — and has secured £71 billion in UK mobile banking transactions and the London 2012 Olympics, advising clients including Microsoft, Cisco, BP, Vodafone, and VISA. He authored the world’s most popular cybersecurity course — a #1 Udemy bestseller taken by over 500,000 students — and was named Cyber Security Educator of the Year 2020, AI Security Educator of the Year, and a UK Top 25 Security Influencer 2025. A DEF CON speaker and featured expert on CNN, Fox News, NBC, and the BBC, Nathan leads StationX’s training of more than half a million students worldwide.