npm Dependencies
npm packages are the dynamic frontier: their shipped JavaScript is untyped, minified, and written against V8. scriptc's answer is the dynamic island — an embedded JavaScript engine (quickjs-ng, ~620KB) that executes dependency code inside your binary, opted in with --dynamic, with every value validated as it crosses back into static code.
The island story
import { Command } from "commander";
const program = new Command();
program
.name("greet")
.argument("<name>", "who to greet")
.option("-u, --upper", "shout it")
.action((name: string, opts: { upper?: boolean }) => {
const text = `hello, ${name}`;
console.log(opts.upper ? text.toUpperCase() : text);
});
program.parse();$ npm install commander
$ scriptc build tool.ts --dynamic -o greet
$ ./greet ada --upper
HELLO, ADAWhat happened, step by step:
- Resolution —
commanderresolves with Node's own resolution algorithm from yournode_modules, honoring package.jsonexportsconditions. - Types — the package's shipped
.d.tsis the type surface your code checks against, exactly as in a Node project. - Embedding — the package's JavaScript (and everything it imports, resolved the same way) is embedded into the binary at build time. The executable never reads
node_modulesat runtime — it runs from any directory on any machine of the same platform. - Execution — embedded code runs in the engine with full JavaScript semantics: the real
commander, unmodified. - The boundary — values cross by copy, never by reference. Your callback's typed parameters (
name: string) are validated at call time: if the package passes something that isn't a string, that's a catchableTypeError, not memory corruption.
Your own code — the .action callback body, the functions it calls — still compiles statically. The engine runs only what must be dynamic, and scriptc coverage --dynamic shows the exact split, including which Node builtins the embedded packages import and whether each is shimmed.
What the island is
- It's quickjs-ng, not V8. Embedded dependency code runs correctly but slower than under Node for CPU-bound work. The win is startup, size, memory, and deployment shape — not raw dependency throughput.
- Shims, not Node. Embedded package code that requires builtins (
node:events,node:path,node:process, ...) gets faithful in-island implementations, several of which bridge to the same native runtime functions static code uses. The coverage report names every builtin the embedded graph reaches; an unshimmed one is reported, never silently stubbed. - The boundary copies. A static value flowing into dependency code marshals by value; mutations made by dynamic code are invisible to the static original, and vice versa. Where JS would alias, scriptc copies — a documented divergence.
any-typed code runs there too. Under--dynamic, expressions the checker types asanyexecute in the engine with full JS semantics, and everyany→ static edge is a validated exit.- One engine per process, created lazily on first use. An island-free
--dynamicbuild emits the same code as a static one.
--npm-static (experimental)
--npm-static <pkg[,pkg…]|auto> asks the compiler to take the named packages out of the island: their shipped JavaScript is compiled statically as program modules, type-informed by their own .d.ts.
This is experimental. Real packages compile statically at high but partial coverage. Sites the static compiler can't take are deferred — the build succeeds and the report names every deferred site, but reaching one at runtime is an error naming the exact unsupported operation. Some packages' code hits the static frontier outright and fails to build statically today; a package the preflight refuses falls back to the island with a coverage note. If a package matters to you statically, try it and read the report — the answer is specific, per package.
--provenance-sources (experimental)
--provenance-sources goes one step further: for packages published with npm provenance attestations, the compiler fetches the package's source at the attested commit and compiles that — TypeScript compiled as TypeScript, instead of the shipped JS. Packages without a usable attestation keep the engine path, with a note, never a failure. Same maturity caveats as --npm-static.
Where this scales
The design goal is unmodified real-world packages. The measuring stick used during development is the published Vercel CLI — straight off the registry, compiled with --dynamic into a single self-contained executable that runs its real workflows, replacing a ~120MB Node runtime plus 181MB of node_modules.
Current limits
- Cross-compiled
--dynamicbinaries are not there yet — the engine archive is built per target; today--dynamicis host-native. See Platform Support. - Packages without shipped or installed type declarations fail the typecheck gate (the standard
Could not find a declaration fileerror) — add@types/<pkg>or a local declaration, as you would in any strict TypeScript project. - Class instances and promises can't flow into
anyslots (no island representation); closures cross as host functions in specific shapes. Each refusal is a compile error naming the fix.