vercel-labs/scriptc: compiling your TypeScript to native binaries
TypeScript-to-Native Compiler. No annotations, no dialect, the same TypeScript you run on Node, type-checked by the real TypeScript compiler and compiled to native.
At a glance
- What is it?
- scriptc turns TypeScript into C, LLVM IR, assembly, native executables and WASI modules using the real TypeScript compiler for parsing and type checking. It is experimental, and its static coverage is the number that decides whether your program can leave Node behind.
- Who is it for?
- Adopt scriptc when you have a small, mostly static TypeScript program, a CLI or a request handler, and you want a single binary without a JavaScript engine. Do not adopt it for a codebase that leans on npm internals or heavy `any` typing unless `--dynamic` and its embedded quickjs-ng are acceptable to you.
- Can I use it commercially?
- Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 4 days ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on September 25, 2026, and from our analysis. They are not legal advice.
DEEP OPEN-SOURCE ANALYSIS
The problem scriptc attacks: TypeScript that still needs a JavaScript engine
TypeScript on the server normally means Node. You type-check, then ship JavaScript, and the runtime that executes it is a JavaScript engine plus a standard library. Bundlers can fold that into one file, but the engine stays. scriptc takes a different route: it uses the TypeScript compiler for parsing and type checking, then lowers the program to typed IR and onward to C, textual LLVM IR, native assembly, object files, executables or WebAssembly modules. The README states that static builds include a small native runtime but no Node and no JavaScript engine, and that code which cannot compile statically is reported as a diagnostic rather than silently falling back. That last sentence is the whole design. If your program contains a construct the compiler cannot lower, you get a message, not a slower binary.
The audience is narrow and specific. It is for people who already write TypeScript and want a binary that does not carry an engine: command line tools, small servers, WASI modules. It is not a drop-in replacement for Node, and the README does not present it as one. The project describes itself as experimental and lists macOS, Linux, Windows and WebAssembly via WASI Preview 1 as targets.
How the pipeline works, from hello.ts to a linked executable
The front end is the TypeScript compiler. scriptc does not implement its own type checker or a dialect with annotations; the README's claim is that it is the same TypeScript you run on Node. After checking, the program becomes a typed intermediate representation. That IR is the hub of the design, because every output tier is a rendering of it: `--emit=ir` writes JSON, `--emit=c` writes readable C, `--emit=llvm` writes textual LLVM IR, `--emit=asm` writes native assembly, `--emit=obj` writes a relocatable object. The README notes that source outputs require only Node, so you can inspect what the compiler decided without installing a C toolchain.
On macOS 15+ arm64, the README says ordinary LLVM-tier executables use a bundled helper and a precompiled runtime pack, and that clang acts only as the platform linker driver rather than compiling program or runtime C. That is a deliberate split: the expensive compilation is done by scriptc's own artifacts, and the system toolchain is reduced to linking. Executable builds still need a platform linker driver and SDK, and explicit C builds, LLVM fallbacks and `--sanitize` additionally need a C compiler. The produced executables do not require Node.
One boundary is worth quoting in substance rather than paraphrasing: `--emit=obj` produces a relocatable program object, not a standalone library. It carries undefined `scr_*` runtime references and a required `scr_runtime_abi_v1` marker, and the README points at `scriptc build --lib --profile ...` as the self-contained archive interface. External object consumption is described as experimental, with `--print=native-link-info` emitting a versioned JSON recipe covering the object's target, `main` entry, exact `@scriptc/runtime` source pack, required system libraries, FFI inputs and ABI marker. The recipe deliberately avoids hidden scriptc cache paths, which matters if you want a build that another machine can reproduce.
Installing scriptc and compiling a first program
The compiler requires Node.js 24 or newer, and the package is installed globally from npm. After that, a two-line TypeScript file is enough to see the whole workflow: write it, run it, then build it into a standalone executable.
npm install -g scriptcCreate `hello.ts` with the example from the README, which reads an argument and falls back to a default name:
const who = process.argv.length > 2 ? process.argv[2] : "world";
console.log(`hello, ${who}`);`scriptc run` compiles and executes in one step, so you should see the default greeting printed to your terminal:
scriptc run hello.tsTo get a binary you can copy elsewhere, use `scriptc build` with an output path. Running it with an argument should print that argument back:
scriptc build hello.ts -o hello
./hello ctateIf you want to see the intermediate result before any native toolchain is involved, stop at a source-level artifact. The README shows these landing in a `.scriptc/` directory, so `ls .scriptc/` should list `hello.ir.json`, `hello.c`, `hello.ll`, `hello.s` or `hello.o` depending on the flag:
scriptc build hello.ts --emit=ir >/dev/null
ls .scriptc/Before building anything larger, run the coverage command. It reports how much of the program compiles statically and gives a coded diagnostic for every dynamic or unsupported site. For the hello program the README shows two statements analyzed, two compiled statically, and the line "fully static, this program has no dynamic remainder."
scriptc coverage hello.tsNode APIs, npm packages and the --dynamic escape hatch
Supported Node APIs compile to the native runtime. The README's example is an HTTP server built with `node:http`, listening on port 8080 and returning JSON. That is a real server with no engine underneath it, which is the most persuasive demonstration the project offers.
import { createServer } from "node:http";
const server = createServer((req, res) => {
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ path: req.url }));
});
server.listen(8080, () => {
console.log("listening on http://localhost:8080");
});npm packages are handled differently, and this is where the design makes a concession. Passing `--dynamic` embeds the package's JavaScript in the executable by embedding quickjs-ng explicitly, and the README states the result does not read `node_modules` at runtime. The example uses `picocolors`:
npm install picocolors
scriptc build cli.ts --dynamic -o cli
./cliThe word "explicitly" is doing real work in that sentence. You are not getting a native compilation of picocolors; you are getting a JavaScript engine inside your binary, chosen on purpose. For a small dependency that only formats strings, that is a reasonable trade. For a dependency graph that pulls in half of npm, the binary is carrying the engine you were trying to avoid, and the static coverage number stops being meaningful for that part of the program. The same applies to `any`-typed code: the README groups npm packages and `any` together as the cases `--dynamic` exists for.
WebAssembly, WASI Preview 1 and the SC3002 boundary
Cross-target builds require Zig, because its bundled WASI libc produces a portable WASI Preview 1 module through the production LLVM backend. Two environment variables drive it: `SCRIPTC_CC=zigcc` and `SCRIPTC_TARGET=wasm32-wasi`.
SCRIPTC_CC=zigcc SCRIPTC_TARGET=wasm32-wasi scriptc build hello.ts --no-keep-c -o hello.wasm >/dev/null
SCRIPTC_CC=zigcc SCRIPTC_TARGET=wasm32-wasi scriptc run hello.tsThe README says the WASI target supports the same executable language tiers as the native targets, including async/await, promises, generators, timers, stdin and readline events, callback and promise filesystem APIs, and `--dynamic`. The boundary is capability-based rather than syntactic. Network sockets and fetch, child processes, OS signals and filesystem watching fail before linking with `SC3002`, because portable WASI Preview 1 does not provide them. Sanitizer builds, native FFI and library-mode archive builds are target diagnostics as well. The README points at the platform support page for the precise boundary, which suggests the list is expected to move.
That is a clean failure mode. You learn at build time, with a code, that the target cannot do what you asked, instead of shipping a module that throws when it first opens a socket. If your program is a file transformer or a stdin filter, WASI is a good fit. If it is an HTTP client, it is not, and no flag fixes that.
Where scriptc is the wrong tool, and what it is not
The README says the project is experimental, and the version numbers agree: v0.0.35, v0.0.34 and v0.0.33 landed within days of each other in August 2026. The last push to the repository was on 2026-08-22. Rapid patch releases at a 0.0.x version are a signal about API stability, not about quality, and anyone pinning scriptc in a production build should treat the CLI surface as moving.
The harder limitation is the static boundary itself. Any construct the compiler cannot lower becomes a diagnostic, which means porting an existing Node service is a coverage exercise before it is a build exercise. The README does not document a rollback path or a gradual migration story, and it does not claim that arbitrary npm packages compile natively. If your program's behaviour depends on a package that reaches into Node internals, `--dynamic` is the answer, and at that point you have embedded quickjs-ng. If you wanted no JavaScript engine at all, that requirement and that dependency are in conflict.
There is also a toolchain dependency that is easy to underestimate. `--emit=ir|c|llvm` needs only Node. Assembly and object emission on macOS 15+ arm64 uses the optional platform helper, which the README says is installed with scriptc and needs no compiler, archiver, linker or SDK, but the helper runs only on macOS 15+ arm64 and emits artifacts with an `arm64-apple-macosx14.0.0` deployment target. Sanitized assembly or object emission is rejected until the helper's AddressSanitizer pipeline matches the executable path. Executable builds need a platform linker driver and SDK. WASI builds need Zig. The set of machines that can produce every artifact tier is smaller than the set of machines that can run Node.
For comparison, quickjs-ng, which scriptc embeds for `--dynamic`, is a small JavaScript engine you can embed directly in a C program. That approach gives you one language at runtime and no TypeScript front end; scriptc's difference is that the static path produces native code with no engine, and the dynamic path is the fallback rather than the product. A plain Node deployment is the other honest alternative: it runs the same TypeScript after stripping types, supports the full npm ecosystem, and gives up only the single-binary, engine-free property that scriptc exists to provide.
Licence, workspace layout and the cost of upgrading
scriptc is Apache-2.0. That is a permissive licence with an explicit patent grant, and it imposes notice and attribution obligations on redistribution. Because the compiler embeds quickjs-ng for `--dynamic`, a binary built that way contains third-party code, and the licence obligations of the embedded runtime are a separate question from scriptc's own licence. The README links to the quickjs-ng repository but does not restate its terms, so anyone shipping a `--dynamic` binary commercially should read that licence directly. This is a description of what the repository states, not legal advice.
The repository is a pnpm workspace. The root `package.json` is private, pins `engines.node` to `>=24.0.0`, and holds the scripts that matter for maintenance: `pnpm -r --filter "./packages/*" run build`, `vitest run` for tests, and `lint` over `packages/compiler/src` and `packages/cli/src`. The dev dependencies pin `typescript` to 5.9.3, and there is a separate `test:ts7` script, which implies the project tracks TypeScript versions deliberately rather than floating them.
Upgrade cost concentrates in two places. The CLI flags and emit tiers are the surface you script against, and at 0.0.x they can change between releases. The native artifacts are the other: rebuilding the optional macOS arm64 pieces requires CMake, Ninja and Homebrew `llvm@22`, then `pnpm --filter @scriptc/llvm-darwin-arm64 build:native` and `pnpm --filter @scriptc/runtime-darwin-arm64 build:native`. The README notes the normal workspace build needs no local LLVM installation, so this cost applies only if you rebuild the shipped native artifacts rather than consuming them. The test suite has its own dependency: `pnpm test:sandbox` loads `.env.local`, preflights Vercel authentication and project access, and uses a managed sandbox image, with `VERCEL_OIDC_TOKEN` preferred and `VERCEL_TOKEN`, `VERCEL_TEAM_ID` and `VERCEL_PROJECT_ID` as the access-token path. Contributing a change therefore requires Vercel credentials, which is a higher bar than running the unit tests.
Editorial conclusion
Adopt scriptc when you have a small, mostly static TypeScript program, a CLI or a request handler, and you want a single binary without a JavaScript engine. Do not adopt it for a codebase that leans on npm internals or heavy `any` typing unless `--dynamic` and its embedded quickjs-ng are acceptable to you. Before committing, run `scriptc coverage` on the real entry point and read the coded diagnostics; that output, not the README, tells you how much of your program can be static.
Frequently asked questions
What does scriptc do?
It compiles TypeScript and JavaScript to typed IR, readable C, textual LLVM IR, native assembly and objects, native executables and WebAssembly modules. It uses the TypeScript compiler for parsing and type checking, and static builds include a small native runtime with no Node or JavaScript engine.
How do I install scriptc?
Install it globally from npm with `npm install -g scriptc`. The compiler requires Node.js 24 or newer, and `--emit=ir|c|llvm` needs only Node.
Does scriptc work with npm packages?
Passing `--dynamic` embeds an npm package's JavaScript in the executable using quickjs-ng, and the README states the result does not read `node_modules` at runtime. The same flag covers `any`-typed code.
Which platforms does scriptc target?
The README says scriptc is experimental and targets macOS, Linux, Windows and WebAssembly via WASI Preview 1. WASI and other cross-target builds require Zig.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/vercel-labs-scriptc)
Community notes