How small and fast can a TypeScript CLI get?
Founding Engineer
Can a TypeScript CLI match the binary size and startup speed of one written in Zig, Rust, or Go?
- Engineering
- 7 min
inth is the CLI for Inth’s organizations, projects, Code Audit and Inbox. It’s written in TypeScript. It does OAuth, keeps tokens in the system keychain, and has an interactive picker. It’s also a 2 MB native binary that prints its version in 3.6 ms, with no Node, no Bun and no JavaScript engine inside.
The compiler behind that is Scriptc, from Vercel Labs. It turns TypeScript into a native executable, and a hello world comes out at 70 KB. This post covers how the build works, what I had to rewrite to get it compiling, and where it still hurts.
Startup against CLIs you already have
Here’s --version, the cheapest thing a CLI can do, for the CLIs I had installed. Each number is the median of 30 runs after 10 warmups, on an M5 Pro running macOS 27.

| Program | Binary | Median |
|---|---|---|
| fx (Zig) | 6.3 MB | 2.2 ms |
| inth (Scriptc) | 2.0 MB | 3.6 ms |
| unkey (Go) | 10 MB | 4.3 ms |
| claude (Bun) | 218 MB | 6.8 ms |
| codex (Rust) | 229 MB | 8.1 ms |
| vercel (Node) | needs Node | 25.8 ms |
| inth, bun --compile | 63 MB | 26.6 ms |
| inth via npm launcher | needs Node | 27.7 ms |
| unkey via npm launcher | needs Node | 50.3 ms |
These are specific binaries, not languages. I spent time profiling inth’s startup, and I don’t know what the other teams did.
The last results was interesting, Inth and unkey are native binaries, but npm install -g puts a small Node script in front of each one, and that script is most of the time. It adds 24 ms to inth and 46 ms to unkey. Codex’s npm shim adds 24 ms too.
The bun --compile row is a Node version of inth that I keep in the repo for comparison. It shares the commands, OAuth flow and output formatting with the Scriptc binary, but still uses Zod, Clack and a keyring addon. Run it directly with bun inth.js and it takes 12 ms, so compiling made it slower. --compile packs everything into one 1 MB bundle, and the slowdown also happens when Bun runs that bundle as a plain file. I haven’t worked out whether that’s parsing or module setup. --bytecode wins back about half the difference.
It isn’t only startup. In a PTY benchmark with fixture data, the organization picker is on screen 5.5 ms after launch in the Scriptc build, and 55 ms after launch in the Node version.
How Scriptc works
Scriptc runs the real TypeScript compiler first, so your types have to check. Then it turns what it understands into native code. Anything it can’t handle is a compile error with an SC code, so you find out at build time. Importing a package’s shipped JavaScript needs --dynamic, which embeds the QuickJS-ng engine. The docs say that adds about 620 KB. A hello world importing one tiny npm package came out at 1.2 MB for me.
I didn’t use --dynamic. Keeping a JavaScript engine out of the binary was the whole point.

Scriptc has a Node compatibility layer, but it doesn’t cover the macOS Keychain, libsecret, Windows Credential Manager, raw terminal mode or file locks. A CLI with login needs most of those on day one. I wrote them in C and called them through Scriptc’s FFI. Here’s one function from start to finish.
Calling C from TypeScript
Reading a saved token on macOS starts as a C function that calls Security.framework:
Strings arrive as a pointer and a length. The secret goes back through a callback, and Scriptc copies it before the callback returns, so C can release the keychain data straight away and nobody has to agree on who frees what.
The build script writes an ffi.json manifest. It describes each function’s signature and lists the object files to link:
In TypeScript it’s a bare declaration:
And calling it looks like calling anything else:
Then one command builds the binary:
The C is about 1,300 lines across 11 files, compiled with -Wall -Wextra -Werror, and exposes 21 functions. The whole build is in scripts/build-scriptc.ts if you want to copy it.
What happened to the dependencies
The Node version of this CLI used four npm packages that didn’t compile. Each one got replaced:
| Package | Used for | Replaced with |
|---|---|---|
| Zod | Validating saved credentials | Scriptc's checked JSON casts, plus a few hand-written checks |
| Clack | The interactive picker | native-terminal.c: termios raw mode and poll() |
| keyring (NAPI addon) | Storing tokens | Security.framework, libsecret, Credential Manager |
| proper-lockfile | Locking the credentials file | flock on macOS and Linux, LockFileEx on Windows |
The Zod replacement is my favorite. Scriptc checks a JSON.parse cast against the target type’s fields at runtime, which plain TypeScript as never does, so this throws if the saved file has the wrong shape:
The typeof checks overlap with what Scriptc already does. The ones that matter are the empty-string and expiry checks, because a type can’t express those.
Most of the C is there because Scriptc can’t reach those OS APIs any other way, and I haven’t shown that it’s faster than the libraries it replaced. A couple of small files, for HTTP dates and URL validation, exist because writing them in C was quicker than fighting the compiler.
Vendoring, and when to stop
Rewriting every dependency doesn’t scale, so I tried vendoring. inrepo pins an upstream repo to a commit, applies patches you keep in your repo, and regenerates a local package. inrepo verify catches drift. Commander and Picocolors compiled after seven patches, and that’s now inrepo’s example.
The dependencies I actually wanted went worse.
- Zod stopped on its first error, a circular import between
core.tsandutil.ts(SC1016). That’s probably a small patch. But it was only the first error, and the checked casts already worked, so I left it. - The skills CLI (vercel-labs/skills) produced 309 errors for its
addcommand alone, across the CLI and its dependencies. That’s a fork. Sointh skillshas its own native picker and shells out to a pinnednpx --yes skills@1.5.25, withINTH_TOKENremoved from the child’s environment. That one command needs Node installed.
Each assessment is a script in the repo that saves the compiler’s full output to assessment.json, so you can rerun them when Scriptc moves.
No stack traces
This is the one limit I couldn’t work around. Here’s what Scriptc says about a probe that logs error.stack:
Scriptc doesn’t keep the stack from where an error was thrown, so no error reporter can recover it afterward. @sentry/core also failed to compile over browser globals like window, but fixing that wouldn’t have helped.
So the CLI uses Sentry’s C SDK, sentry-native 0.16.2, built as a static library with its crash handlers and networking turned off. It only starts after an unexpected error. C builds the event, a scrub_event hook rebuilds it from an allowlist, and TypeScript sends the bytes with fetch and a one-second timeout. Reports carry a fixed diagnostic code instead of the error message, because messages can contain tokens and file paths.
The frames Sentry does get point at the sentry_capture call, which tells you nothing about where the error was thrown. Every event is tagged stack_origin: report_site and grouped by command, error type, diagnostic code and the last step the CLI recorded. That’s usually enough to find the command that broke and narrow it down from there.
Smaller things that don’t compile
As of 0.0.36, each of these has a comment in the source explaining the workaround:
| What breaks | What I did instead |
|---|---|
| Error.cause | Put sanitized HTTP details in the message |
| Named capture groups read through ?., or on a regex that isn't a literal | Indexed captures |
| array.push(a, ...rest) | Two push calls |
| Passing a class instance where an interface with methods is expected (SC2002) | Two small methods that return an object literal with exactly the interface's members |
One more, and this one wasn’t Scriptc’s fault. help.ts built the entire help text when the module loaded, on every run. Making it lazy took --version from 8.0 ms to 6.1 ms on an earlier build. When the whole process lasts a few milliseconds, top-level work you’d never notice in Node is a big share of the time.
Would I do it again?
For this CLI, yes. The main cost is libraries. cargo add keyring sentry clap toml would have replaced most of my C, and Rust and Go have maintained libraries for every OS call I wrote by hand. If your CLI is mostly OS plumbing, I’d pick one of those.
For this build I left out Scriptc’s JavaScript engine, so npm is the toolchain and the install channel, and nothing more. No JavaScript from npm ends up in the binary, and every package I wanted was a rewrite or a vendoring job.
Everything here is in inthhq/inth: the build script, pnpm bench, pnpm bench:cross for the table above The versions are all listed, so you can run the table on your own machine.