12k
All articles

Compile TypeScript to a Native Binary With scriptc

scriptc compiles TypeScript to native binaries, with static builds, a 620KB dynamic engine, coverage checks, and clear diagnostics for blocked code.

OpenReplay Team
OpenReplay Team
Compile TypeScript to a Native Binary With scriptc

scriptc compiles ordinary TypeScript into a self-contained native executable. A static build ships no JavaScript engine, give or take a regex interpreter it links only if your code uses regular expressions. The real TypeScript compiler type-checks the program, scriptc lowers it to a typed intermediate representation, and native code comes out the other end.

If you ship a CLI written in TypeScript, you know the trade. The tool is 40KB of logic. The delivery mechanism is a 100MB runtime, an install step, and a startup cost the user notices.

The interesting part is not the binary. Other tools produce one by packing a runtime inside it. scriptc leaves the engine out where it can, and it tells you which parts of your program it can and cannot handle. This article covers the three outcomes any construct can land in, and the one command that tells you where your own code falls.

Key Takeaways

  • scriptc compiles the TypeScript you already write. There is no dialect to learn, nothing to annotate and no replacement standard library, and type checking runs through the real TypeScript compiler.
  • Static compilation is the default and the only mode unless you pass --dynamic, which embeds quickjs-ng at roughly 620KB into the binary.
  • Anything that fits neither tier stops the build. You get an SC code, the offending lines and usually a suggested rewrite, instead of a binary that is subtly wrong.
  • Running scriptc coverage gives you a per-statement verdict: which parts make the static tier, which parts would pull in the engine, and a coded diagnostic naming each blocker.
  • Most npm packages ship plain JavaScript plus separate declaration files, which gives the static tier no typed source, so real dependency trees pull the embedded engine back into the binary.

What Is scriptc, and How Does the Pipeline Run?

scriptc takes a .ts entry point, type-checks it with the TypeScript compiler, lowers the checked program to a typed IR, and emits native code from there. The scriptc README makes LLVM the default code generator and keeps C as a permanent readable reference backend you select with --backend c, so “TypeScript to C to clang” describes only one of the two paths. The source you feed it is the source you already run on Node.

Install is a global npm install, and executable builds need a linker driver on the host:

npm install -g scriptc

The Quickstart puts the compiler on Node 24 or newer. Executable builds also need a platform linker and a matching SDK or sysroot, and Platform Support is specific about the rest: on supported macOS, Linux and Windows hosts the LLVM tier links a precompiled runtime pack, so a C compiler is only required for explicit C builds, LLVM fallbacks and --sanitize. Source output selected with --emit=ir|c|llvm needs nothing but Node.

A minimal program and the two commands that matter:

// slug.ts
function slug(title: string): string {
  return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
console.log(slug("Compile TypeScript to a Native Binary"));
scriptc run slug.ts
scriptc build slug.ts -o slug && ./slug

scriptc run compiles and executes in one step, which is what you want in a watch loop. scriptc build -o produces the artifact you actually ship.

Tier 1: Compiled Statically, the Default

Static compilation is the default in scriptc and the only mode you get unless you explicitly opt out. On the scriptc homepage, tier 1 is presented as everyday TypeScript: classes and closures, async/await, the standard library, and the parts of Node most programs reach for, such as fs, path, process and http. All of it turns into native code, and the binary holds no engine.

The detailed surface goes further than a headline list suggests. The introduction page sorts it into three groups. On the language side you get single-inheritance classes with dynamic dispatch, closures that capture the way JavaScript does, generic function declarations resolved by monomorphization, discriminated unions handled through TypeScript’s own narrowing, async/await scheduled exactly as JavaScript schedules it, exceptions with finally, destructuring, spread, accessors, iterators and template literals. The standard library group covers strings, arrays, Map and Set, JSON, Math, typed arrays and the Error hierarchy. The Node group reaches fs in both its sync and promise forms, plus path, process, child_process, os, crypto, url/URL, zlib and timers, and it includes the whole server stack: net, http, https, tls, dgram, dns and readline.

That means an HTTP service compiles, not just a pure function:

// server.ts
import http from "node:http";

http.createServer((req, res) => {
  res.writeHead(200, { "content-type": "application/json" });
  res.end(JSON.stringify({ path: req.url }));
}).listen(3000);
scriptc build server.ts -o server && ./server

Any enumeration of the static surface decays as the compiler moves. The changelog tracks it release by release, and every release also ships a machine-readable surface-manifest.json listing the language and standard-library surface the static tier handles at that version, with stable per-entry ids so tooling can diff two releases. That file outlasts any prose list, including this one.

Tier 2: The Dynamic Tier and Its 620KB Engine

Passing --dynamic embeds a JavaScript engine into the binary, and nothing else does. The npm dependencies guide calls the result a dynamic island: an embedded engine of roughly 620KB that runs whatever cannot be static, which in practice means the JavaScript shipped by npm packages and anything the checker types as any. Values are validated as they cross back into static code. The engine is quickjs-ng.

npm install picocolors
scriptc build cli.ts --dynamic -o cli

The design point is the opt-in. A scriptc binary never silently grows an engine; the 620KB is always something you asked for. Two consequences follow from that. The package’s JavaScript goes into the executable at build time, so the finished binary is self-contained and has no reason to look at node_modules when it runs. And the boundary is checked rather than trusted: a declaration file that promised string and delivers an object throws a catchable TypeError instead of corrupting memory in native code that assumed otherwise.

Tier 3: Rejected at Compile Time

Code that scriptc will not compile statically and cannot route through the dynamic tier fails the build. The promise the homepage makes for this tier is that the failure is legible: a specific error code, the offending lines, and in most cases a hint about how to rewrite them. Nothing is quietly turned into something almost equivalent. Diagnostic codes carry an SC prefix, and SC3002 is the one you meet on the WASI target: sockets and fetch, child processes, signal APIs and fs.watch all stop the build before the link step, because Preview 1 gives a guest no way to do any of them.

This three-way split is the reason the rest of the design is worth taking seriously. A compiler that quietly degraded a construct into something almost equivalent would make every performance and semantics claim conditional. Refusing to emit, with a line number and a suggested rewrite, is what makes the static tier’s promise checkable.

How Does scriptc coverage Tell You If Your Code Qualifies?

scriptc coverage is how you answer “would my code compile” without migrating anything. It walks the program statement by statement and reports which ones make the static tier, which ones would need the engine, and what is blocking the rest, with a diagnostic code attached to every blocking site. Run it on your real entry point, not a toy file.

The Quickstart walks a two-statement hello.ts through the command: 2 statements analyzed, 2 compiling statically, 100%, and a verdict line saying the program has no dynamic remainder. The README’s example is a real project instead, and reports 4451 of 4481 statements static, or 99%. A realistic project prints a lower percentage and a list of named sites. Read it in three passes: the headline percentage tells you whether the project is a candidate at all; the per-site diagnostics tell you what is blocking; and the identity of each blocker tells you which fix applies.

Blockers split cleanly into two kinds. An untyped npm import is not something you rewrite, it is something you accept, and it means building with --dynamic. A loose type in your own code usually is fixable:

// forces the dynamic tier: the payload is any
function port(config: any): number {
  return config.port + 1;
}

Declare the shape and the same function compiles statically:

interface Config { port: number }

function port(config: Config): number {
  return config.port + 1;
}

Where analysis stops early, on a type error or an import fence, the changelog records that coverage now prints the same diagnostics a failed build would, code frames and all, rather than a bare summary line. Adding --dynamic to the command goes further and tells you which sites the embedded engine would end up running.

What Numbers Does the Project Publish?

The homepage puts a hello-world binary at roughly 320KB, with a startup of about 4ms and libSystem as its only linked library, against a Node runtime of about 120MB that takes roughly 35ms to print the same line. The README’s benchmark table is more optimistic about the same workload: 170 to 200KB and about 2.4ms of startup, against Node’s ~47ms. The two project sources do not agree, so it is worth knowing which one a given number came from. Either way, these are the project’s own figures for hello-world on its first-class macOS host, not a general claim about your application.

Treat them as a floor rather than a forecast. A binary built with --dynamic carries the engine and the embedded package JavaScript, so the size class changes. The figure that transfers cleanly to your own estimate is the 620KB engine cost, because it is a fixed, documented addition you either take or avoid.

What Does Adoption Actually Cost?

scriptc lives under the vercel-labs namespace and is still on 0.1.x. Community discussion since its release in late July 2026 has centred on exactly that status: whether a Labs project accumulates the years of maintenance that a compiler in your build pipeline demands. The repository publishes tagged npm releases and an Apache-2.0 license, but no support or SLA statement accompanies them.

The sharper practical limit is the ecosystem. Most npm packages ship compiled JavaScript alongside separate .d.ts declarations, which gives the static tier no typed source to compile, so that code runs in the embedded engine under --dynamic and the engine ships with your binary. Packages with no declarations at all do not degrade quietly: they fail the typecheck gate with TypeScript’s standard missing-declaration error, exactly as they would in any strict TypeScript project. Other rough edges are documented individually, down to details like scriptc run not forwarding extra CLI arguments to the program, and the limitations page is the list worth reading before you plan a migration.

The honest shape of the fit: a tightly typed CLI or small service with few or no runtime dependencies is a strong candidate, and a project with a deep dependency tree is buying a 620KB engine plus embedded JavaScript for most of its code. Install the CLI, run scriptc coverage on your entry point, and let the percentage and the blocker list decide instead of the headline.

FAQs

Does a machine running a scriptc binary need Node.js or clang installed?

No. Everything scriptc needs is a build-time requirement. The compiler runs on Node.js 24, and executable builds need a platform linker driver plus a matching SDK or sysroot. On supported macOS, Linux and Windows hosts the LLVM tier links a precompiled runtime pack instead of compiling C, so a C compiler such as clang is only required for explicit C builds, LLVM fallbacks and sanitizer builds. The executables themselves do not require Node: a static build ships a small native runtime, with no Node and no JavaScript engine beyond the regex interpreter linked in when your code uses regular expressions. Source emission with the ir, c and llvm emit targets needs Node alone.

Can scriptc build Linux or Windows binaries from a Mac?

Yes. scriptc targets macOS, Linux, Windows and WebAssembly via WASI Preview 1, with macOS arm64 as the first-class host. Cross-compiling through zig is one route to Linux and Windows binaries, and both targets also have native helpers and runtime packs of their own, covering Linux x64 and arm64 and Windows x64. The WASI path is driven by the SCRIPTC_CC and SCRIPTC_TARGET environment variables set to zigcc and wasm32-wasi, and APIs absent from Preview 1, such as sockets, child processes and filesystem watching, fail before linking with SC3002.

What happens when an npm package running in the embedded engine mutates an object you passed it?

The static side never sees the mutation. In a dynamic build, values are copied across the boundary rather than shared, so anything the engine-executed package changes leaves the static original untouched, and anything static code changes leaves the engine's copy untouched. scriptc lists this as one of its deliberate departures from JavaScript, where both sides would be holding the same object.

Can npm dependency code be compiled statically instead of running in the engine?

Yes, with the experimental --npm-static flag. You name the packages, or pass auto, and the compiler tries to pull them out of the embedded engine and compile their shipped JavaScript as static program modules, typed by their own declaration files. Coverage is high but partial: sites the static compiler cannot take are deferred and named in the report, and a package the preflight turns down goes back to the engine with a note rather than breaking the build. Run coverage to see which of your packages clear it.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.