What Breaks When You Compile TypeScript to a Native Binary with scriptc

What Breaks When You Compile TypeScript to a Native Binary with scriptc

pr0h0•
typescriptcompilersnative-codetoolingvercel
AI Usage (93%)

A report published on 2026-09-25 says Vercel Labs shipped scriptc, a TypeScript-to-native compiler that leaves the JavaScript engine behind and pays you back in startup time and memory. That is the whole claim I can verify: the release happened, and those are the stated benefits. The report is a single secondary item — not a Vercel Labs announcement, repository, or changelog — and it gives no version number, no supported-feature list, no benchmark methodology, and no primary source to link.

So this post is about what breaks when you compile TypeScript to a native binary with scriptc: which language features, runtime assumptions, and ecosystem dependencies have to be reimplemented, restricted, or quietly dropped once V8 is no longer underneath you. A native binary almost certainly starts faster than node, but that is not the interesting question. The interesting one is how much of your code depends on the engine's behaviour, and how you find that out before production does. Because I could not verify scriptc's internals, treat the architecture discussion below as inference from the class of compiler it appears to belong to, not documented behaviour.

What "Compiling TypeScript to a Native Binary" Actually Means

Three architectures get sold under the same phrase, and the distinction decides everything downstream.

  1. Bundle plus embedded engine. Transpile TypeScript to JavaScript, pack the output and a JavaScript engine into one executable. Node's Single Executable Applications does this; so does Bun's --compile. You get single-file distribution and you skip module resolution and part of the startup cost, but the runtime semantics are V8's (or JSC's) exactly.
  2. Restricted subset to IR to machine code. A TypeScript-like language that compiles through an intermediate representation to native code, with a smaller object model. AssemblyScript and Porffor sit here. Real compilers to native, but they are not "TypeScript" in the sense your node_modules expects.
  3. AOT JavaScript with static analysis and a fallback. Compile what can be statically resolved, and keep an interpreter or a runtime for what cannot — the shape Static Hermes has been exploring. Full-ish semantics, retained interpreter, larger binary.

The report's phrasing — "leaves the JavaScript engine behind" — rules out option 1 if the word "engine" is being used precisely. My working assumption is that scriptc sits closer to 2 or 3, and it is only an assumption: "compiles to a native binary without an engine" and "embeds an engine in a native binary" are different products. Any claim about its internals needs a primary source before it is worth repeating.

Why the JavaScript Engine Is Load-Bearing for TypeScript

V8 is not just a parser and a bytecode loop. It is the thing that makes the following work, and a native compiler has to replace it, restrict it, or refuse it:

  • Shape-based property access and inline caches. Polymorphic call sites get a cache; megamorphic ones fall back to a hash lookup. The object model is designed around shape churn.
  • Prototype chain mutation. Object.setPrototypeOf, __proto__, monkey-patched built-ins. Every one of these invalidates assumptions the compiler may have baked in.
  • JIT tiering and deoptimisation. Ignition → Sparkplug → Maglev → Turbofan, with bailouts back down. See the V8 Sparkplug post for how the non-optimizing tiers were added to reduce the cost of that ladder.
  • Precise garbage collection over a heap full of dynamically typed values.
  • Proxy and Reflect traps, where the property model itself is user code.
  • Host APIs: process, Buffer, fs, fetch, crypto, Intl, timers, TextEncoder, URL, streams, worker_threads, AsyncLocalStorage.

Call this the compatibility budget. It is not a wishlist. Each item gets implemented natively, rejected with a compile error, or silently diverges at runtime — and the third option is the one that costs you a weekend.

What Breaks First: TypeScript Features That Aren't a Language Problem

Ordered roughly by how often ordinary application code hits it.

Type Erasure Leaves the Compiler Nothing at Runtime

TypeScript's types do not exist after emit — that is the whole design, documented under Erased Types. Structural typing and branded types are checked, then discarded. A compiler looking for a runtime type to dispatch on finds nothing:

type UserId = string & { readonly __brand: "UserId" };

interface Payload { id: number }

export function run(raw: string): string {
  const parsed = JSON.parse(raw) as Payload;   // `any` at runtime, no shape check
  const id = parsed.id as unknown as UserId;   // brand erased at emit
  return typeof id + ":" + String(id);
}

Under Node, parsed.id is whatever JSON.parse produced. A compiler that tried to honour the brand would have to materialize it, which changes what typeof returns and what crosses a serialization boundary. Most likely a compiler picks one representation for any — a boxed dynamic value — and every any in your code becomes a performance cliff rather than a compile error. That part is my reading of scriptc's likely design. It is not my reading of TypeScript's.

Dynamic Property Access Breaks Static Shape Assumptions

Computed keys, for...in, spread, delete, and prototype patching all assume a shape-flexible object model:

const KEY = process.env.FIELD ?? "name";

export function probe(obj: Record<string, unknown>) {
  let n = 0;
  for (const k in obj) n++;              // enumeration order, inherited keys included
  const copy = { ...obj, extra: true };  // new shape
  delete copy.extra;                     // may force dictionary mode
  return [obj[KEY], n];                  // key unknown until runtime
}

V8 handles this because objects have hidden classes and inline caches — the mechanism is well explained in Shapes and Inline Caches. An AOT compiler wants monomorphic, statically known shapes, so obj[KEY] is the exact point where it must either emit a generic lookup path or give up. Test this specific pattern on day one; it produces more "compiles fine, wrong answer" bugs than anything else on this list.

eval, new Function, and Dynamic Imports Need a Runtime

export function makeParser(src: string) {
  return new Function("x", "return (" + src + ");");
}

The callee does not exist at compile time. A native compiler has three moves:

OptionWhat it costs
Reject at compile timeLoud and safe, but kills template engines, some ORMs, config loaders, and validation libraries that build functions from strings
Warn, then throw at runtimeCompiles, ships, and fails in production on the one branch your tests never hit
Ship an interpreterPreserves semantics, but pulls in an evaluator, a full object model, and a GC — much of what the engine was going to cost you anyway

Dynamic import(variable) is the same shape of problem, minus the evaluator: the module graph is only partly knowable, so the compiler either bundles a superset or fails at runtime.

Host APIs and Globals a Native Binary Has to Provide

A binary that does not embed an engine still has to provide the global surface, or code will not link. Rough split for a CLI:

  • Table stakes: process.argv, process.env, stdout/stderr, node:fs, node:path, URL, TextEncoder/TextDecoder, timers, fetch, node:crypto basics.
  • Plausibly missing or partial: Intl (full ICU data is tens of megabytes), node:vm, worker_threads, AsyncLocalStorage, stream internals, perf_hooks, and anything requiring .node addon loading.

Probe it rather than guess:

const probes: Array<[string, () => unknown]> = [
  ["process.argv",      () => process.argv.slice(0, 2)],
  ["process.env",       () => typeof process.env.PATH],
  ["fs.readFileSync",   () => typeof require("node:fs").readFileSync],
  ["fetch",             () => typeof fetch],
  ["TextEncoder",       () => typeof TextEncoder],
  ["Intl.NumberFormat", () => new Intl.NumberFormat("de-DE").format(1234.5)],
  ["structuredClone",   () => typeof structuredClone],
  ["Proxy",             () => typeof Proxy],
  ["require.resolve",   () => typeof require.resolve],
  ["worker_threads",    () => typeof require("node:worker_threads").Worker],
];

for (const [name, fn] of probes) {
  try { console.log(name, "ok", fn()); }
  catch (err) { console.log(name, "FAIL", (err as Error).message); }
}

(require here assumes a CJS-targeted build; swap in createRequire if not.)

The Real Test Is node_modules, Not Your Entry File

A hello-world benchmark tells you nothing, because the hard part is not your code — it is CJS/ESM interop, exports condition maps, deep transitive graphs, require() with a computed path inside a package, optional dependencies resolved in try/catch, .node native addons, WASM, worker threads, and require.resolve. A compiler's supported dependency set is its feature matrix. Until someone publishes the list of packages that build and run, you determine it empirically, and "it built" is not the same as "it ran".

⚠️

A package compiling successfully does not mean its runtime branches were exercised. Most packages guard optional dependencies, alternate encodings, and platform paths behind try/catch and feature checks. A successful build proves the compiler parsed the module graph, not that those branches behave correctly.

How to Reproduce the Compatibility Check on Your Own Code

I could not verify a scriptc release, version, or CLI, so I am not quoting numbers — not mine, not anyone else's. Here is the procedure I would run, and the shape of the harness.

One file per failure class from the sections above: 01-erasure.ts, 02-dynamic-property.ts, 03-eval.ts, 04-host-apis.ts, 05-nodepath.ts (a real dependency that pulls in something awkward). Then:

## Record the environment first — a timing without a machine is not a result.
uname -srm && node --version && lscpu | grep 'Model name'

## Build once, produce two artifacts
<tsc-or-tool> src/*.ts --outDir dist-node
<scriptc>     src/*.ts --outDir dist-native

## Compare, cold and warm reported separately
hyperfine --warmup 3 --runs 20 \
  './dist-native/04-host-apis' \
  'node dist-node/04-host-apis.js'
Feature classExpected under Node/V8Observed under scriptc
Erased brand at JSON boundaryplain number, brand is a no-opnot tested
obj[runtimeKey]lookup by runtime stringnot tested
for...in mixed keysspec enumeration ordernot tested
delete then reusecorrect, possibly dictionary-modenot tested
new Functioncompiles and runsnot tested
import(variable)resolves at runtimenot tested
process.argv / envpresentnot tested
Intl.NumberFormatpresent, full ICUnot tested
worker_threadspresentnot tested
.node native addonloads via dlopennot tested

The middle column is what the ECMAScript spec and Node's documented API require. The right column is empty on purpose: I have no verified build to run. Fill it in yourself before you believe anything else here.

Measuring Startup Time and Memory Honestly

Use /usr/bin/time -v for a single cold run (wall time plus peak RSS) and hyperfine for repeated runs. hyperfine warms the page cache, which is exactly why a cold-start claim measured with hyperfine --warmup 0 and one measured with --warmup 5 mean different things — say which you did. For in-process memory use process.memoryUsage():

process.on("exit", () => {
  const m = process.memoryUsage();
  console.error(JSON.stringify({ rss: m.rss, heap: m.heapUsed }));
});

If process.memoryUsage does not exist in the native binary, that is itself a finding about the host API surface, and you fall back to external RSS measurement.

Where Native TypeScript Binaries Actually Pay Off

CLI tools and one-shot scripts where startup dominates and the process exits in milliseconds. That is the clear win, and it is the case V8's startup cost was never designed for. Serverless and edge cold starts are the same argument at a different scale. Single-binary distribution to machines with no runtime is the other real win, independent of speed.

The counter-case matters just as much. A long-running server spends seconds in warmup and hours in optimized code, where V8's tiering is an advantage rather than overhead — a native binary that trades peak throughput for startup is a bad deal there. Code that leans on metaprogramming, Proxy-based abstractions, or native addons is, right now, a poor fit, and I expect that to stay true for a while.

Practical Steps Before You Try scriptc on Real Code

  • Pin the exact compiler version. Without published version numbers or a changelog in the report, a floating version is an untraceable build.
  • Keep Node as the fallback path. Two output targets, one switch, so rollback is a config change. This is the mitigation for every failure class above at once.
  • Add a compatibility smoke test to CI that exercises each dynamic pattern your code actually uses: computed keys, new Function, dynamic import, require.resolve, optional dependencies. Cover the 02–03 classes specifically.
  • Isolate dynamic or addon-dependent code behind a subprocess boundary. Template rendering and anything loading .node go out-of-process, where semantics are unchanged.
  • Treat advertised performance numbers as a hypothesis to test against your own workload, with your own cold/warm split.

Conclusion

What matters about a project like scriptc is not that it makes TypeScript faster in the abstract. It is that it drags the implicit runtime contract into the open. Erasure, dynamic property access, runtime code loading, and the host API surface were always assumptions you were making — V8 just hid them well enough that nobody had to write them down. A native compiler refuses to hide them, and the compile errors and runtime divergences are that contract being read back to you.

A better benchmark screenshot would not change this assessment. Two things from the maintainers would: reproducible benchmarks that state the machine, the command, and the run count, and a documented list of supported features and globals. Until those exist, the report tells me something interesting shipped — and nothing more.

Further Reading

Note on sources: the news item this post is based on is a secondary report (news.lavx.hu, 2026-09-25). I could not locate a verifiable Vercel Labs announcement or repository page for scriptc, so no primary link is given here rather than a guessed one. If you have the official link, it belongs in this list ahead of everything else.

Share this post

More posts

Comments