Skip to main content
Back to the blog

Published

How small and fast can a TypeScript CLI get?

Kaylee Williams

Founding Engineer

Can a TypeScript CLI match the binary size and startup speed of one written in Zig, Rust, or Go?

Topic
Engineering
Reading time
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.

Article data table
ProgramBinaryMedian
fx (Zig)6.3 MB2.2 ms
inth (Scriptc)2.0 MB3.6 ms
unkey (Go)10 MB4.3 ms
claude (Bun)218 MB6.8 ms
codex (Rust)229 MB8.1 ms
vercel (Node)needs Node25.8 ms
inth, bun --compile63 MB26.6 ms
inth via npm launcherneeds Node27.7 ms
unkey via npm launcherneeds Node50.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:

// src/native/native-secret-macos.c (abridged)
typedef void (*inth_secret_callback)(const uint8_t *, size_t, void *);

int32_t inth_secret_read(const uint8_t *service, size_t service_len,
                         const uint8_t *account, size_t account_len,
                         inth_secret_callback callback, void *context) {
  // ...build the query, call SecItemCopyMatching...
  // Scriptc copies these borrowed bytes before this callback returns.
  callback(CFDataGetBytePtr(data), (size_t)CFDataGetLength(data), context);
  CFRelease(result);
  return status;
}

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:

{
  "ffi_format": 3,
  "functions": [
    {
      "name": "secretRead",
      "params": [
        "string",
        "string",
        {
          "callback": {
            "id": "value",
            "lifetime": "call",
            "params": ["string", { "context": "value" }],
            "returns": "void"
          }
        },
        { "context": "value" }
      ],
      "returns": "i32",
      "symbol": "inth_secret_read"
    }
  ],
  "libraries": ["native-secret-macos.o", "toml.o", "Security.tbd"]
}

In TypeScript it’s a bare declaration:

// native-bindings.ts
export declare function secretRead(
  service: string,
  account: string,
  receive: (value: string) => void
): number;

And calling it looks like calling anything else:

// native-keychain.ts
let value: string | null = null;
const status = secretRead(this.service, this.account, (received) => {
  value = received;
});
if (status === -25_300) return null; // errSecItemNotFound

Then one command builds the binary:

scriptc build src/inth.ts --ffi ffi.json -o inth

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:

Article data table
PackageUsed forReplaced with
ZodValidating saved credentialsScriptc's checked JSON casts, plus a few hand-written checks
ClackThe interactive pickernative-terminal.c: termios raw mode and poll()
keyring (NAPI addon)Storing tokensSecurity.framework, libsecret, Credential Manager
proper-lockfileLocking the credentials fileflock 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:

// src/native/native-protocol.ts
export const parseCredentials = (text: string): Credentials => {
  // SAFETY: Scriptc validates the exact record's field types at this checked JSON boundary.
  const value = JSON.parse(text) as Credentials;
  if (
    typeof value.access_token !== "string" ||
    !value.access_token ||
    typeof value.refresh_token !== "string" ||
    !value.refresh_token ||
    !positive(value.expires_at)
  ) {
    throw new Error("Invalid saved credentials.");
  }
  return value;
};

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.ts and util.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 add command alone, across the CLI and its dependencies. That’s a fork. So inth skills has its own native picker and shells out to a pinned npx --yes skills@1.5.25, with INTH_TOKEN removed 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:

stack-probe.ts:8:17 - error SC2020: 'Error.stack' is part of the standard library types but has no scriptc lowering yet

  7 |   if (error instanceof Error) {
  8 |     console.log(error.stack || "No stack available");
    |                 ^~~~~~~~~~~
  9 |   }

  hint: stack traces are not captured (frames would need runtime bookkeeping); name, message, and toString() are available

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:

Article data table
What breaksWhat I did instead
Error.causePut sanitized HTTP details in the message
Named capture groups read through ?., or on a regex that isn't a literalIndexed 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.