Limitations
Honesty is the product. The static surface is large but not total, and a few behaviors diverge from Node by design. This page is the plain-language version; scriptc coverage on your program is the real answer for your code, and every blocker it reports is specific and coded. Nothing on this page is silent: everything here is either a compile error or a documented, numbered divergence.
What doesn't compile (yet)
These are rejected at compile time with an SC code, a code frame, and usually a rewrite hint. A non-exhaustive tour of the ones you're most likely to meet:
Language edges
- Loose
==/!=comparisons compile between two numbers, two strings, or two booleans, and for thex == null/x != nullnullish idiom. Other coercing comparisons are rejected — use===/!==. break/continuecrossing afinallyblock, and anyreturn/break/continueout of thefinallybody itself, are fenced. An ordinaryreturnthroughfinallycompiles.- Generics monomorphize when the target resolves statically. The remaining edges include generic functions declared inside another function, generic class expressions, generic classes whose base depends on their own type parameters, and generic methods that would require dynamic dispatch.
- Generic function values must be pinned at use to a concrete signature and come from a never-reassigned binding; unpinned or rebound values remain fenced. Built-in and standard-library functions often have call-only lowerings, so values such as
const g = Math.floorare rejected — call them directly.
Types and shapes
- Record shapes are exact structs. Passing
{a, b}where{a}is expected is SC2002. Where the compiler does accept a strict field-subset flow, it copies the record — see divergences below. - Union edges: unions used whole where a per-arm answer is needed (reading
u.lengthonstring | string[]— narrow first), union-into-union widening outside the re-tag and width rules, function arms beside data arms. (Printing a whole union is fine:console.log(u)dispatches per arm.) - Watch for tuple inference:
Promise.all([work(1), work(2)])infers a tuple type, and tuple edges (like.joinon a tuple) are fenced. Type the array first:
const jobs: Promise<number>[] = [work(1), work(2), work(3)];
const results = await Promise.all(jobs); // number[] — compilesundefinedandnullcompile both as standalone values and as union arms. Optional record and class fields compile, as do optional/default/rest parameters.
Standard library
- The type checker sees the full standard library; only the supported surface compiles. Reaching declared-but-unlowered surface is SC2020 with the supported alternatives in the hint — e.g. parts of the regex API (
re.exec),Symbol,globalThis, array/Map/Set methods beyond the lowered sets. Datevalues support zero-argument construction, one number/string argument, storage and passing,getTime/valueOf,toISOString, the local and UTC calendar getters, andgetTimezoneOffset. Date-armed unions, the year/month field constructor, setters, identity comparisons, throwingDatevalues,Date.parse, and locale/string formatters remain fenced; the supported string grammar is described below.- Map keys and Set elements are strings and numbers; other key types are fenced.
The any/unknown boundary
anywithout--dynamicis a compile error (SC2011) — useunknownand a checked cast, or opt into the engine.anyandunknownride locals, parameters, and returns — never class fields, array elements, or union arms (record and tuple fields holdunknown).- Operations on
unknownbeyond the supported surface (truthiness,typeofnarrowing, property access,+,switch,throw) need a checked cast first.
Type surface
scriptc typechecks your program in its own type world: the standard es2025 lib plus its own ambient declarations. In enumerated places those declarations are deliberately different from stock lib or @types/node, typed as what actually compiles — JSON.parse returns unknown (not any), pop() returns T (not T | undefined), the Promise executor's reject reason is pinned to Error. So "programs that typecheck" means scriptc's type world, and a program clean under its own tsc can still be redirected here — but never with a bare type error it can't reproduce: the tightened declarations get a second-chance preflight (a program clean in the project's own type world passes, and the affected sites meet per-site diagnostics with rewrite hints instead), and where the shipped fallback declarations lack a lowering for a stock member (console.table, console.time, ...), the member is declared anyway so the call lands on SC2020 naming the supported alternative. console.log/info/debug/error/warn themselves take unknown arguments and render with Node's console semantics — strings verbatim, everything else through the static util.inspect.
Run scriptc build on a file to get the full, current list for that program — the compiler is always more up to date than this page.
What diverges by design
A static-tier program otherwise produces byte-identical stdout and the same exit code as Node. Every known divergence is deliberate and pinned by the differential test suite; these are the ones with real consequences:
Arrays are dense — invalid indices trap. scriptc arrays have no holes and no undefined elements. Where JS would produce undefined (out-of-bounds read, pop() on empty), the runtime prints a RangeError message and aborts. This catches real bugs — but it means process.argv[2] with no third argument is a trap, not undefined:
// JS habit — traps when argv[2] doesn't exist:
const who = process.argv[2] ?? "world";
// scriptc-safe (the length check is explicit instead of implied):
const who = process.argv.length > 2 ? process.argv[2] : "world";Runtime traps are not catchable. User throw is fully catchable, and runtime failures Node models as exceptions (JSON parse errors, checked-cast failures, fs errors, regex errors) throw real error objects — but the hard-trap set (array index violations and friends) aborts the process rather than throwing.
A lying cast on dynamic data throws instead of corrupting memory — the headline divergence, and the point. JSON.parse(s) as Config with mismatched data throws a catchable error naming the offending path (expected number at $.port, got string) where JS would silently hand you garbage.
Structural width subtyping copies. A record flowing into a strict field-subset shape is copied, not aliased: mutations through the narrower reference are invisible to the original. Same stance at the dynamic boundary: values cross by copy, never by reference.
Object.keys/values/entries and JSON.stringify report a record's declaration order, not per-object insertion order. Identical to Node whenever objects are built in declaration order (the overwhelmingly common case).
Strings are stored as UTF-8. Invisible through .length and the string methods (which compute UTF-16 semantics) — except relational comparison (<, >), which uses code-point order, and surrogate-splitting operations, which produce U+FFFD.
Date parsing and timezone data are bounded. The one-string constructor accepts ECMAScript date-only forms, date-times with an explicit Z or numeric offset, and the GMT certificate-validity strings returned by the supported X509 surface; other V8-specific forms and offset-less local date-times produce Invalid Date. Local calendar getters use the operating system's timezone database, so historical results can differ when it and Node's timezone data disagree. When a platform's calendar API cannot represent an otherwise-valid extreme year, scriptc queries its zone rule at a calendar-equivalent year in the 400-year Gregorian cycle.
Memory is reference-counted. Acyclic values free deterministically; reference cycles are collected at deterministic collection points, not by a concurrent GC. Cycles that cross the static/island boundary are uncollectable by either side.
Process shape — process.argv[0] is "scriptc" and argv[1] is the binary's path (positions line up with Node; argv[2] onward are your args). The uncaught-exception stderr line reads Uncaught <value> instead of Node's stack-trace block (exit code and pre-throw stdout are identical). Runtime errors carry message and Node's code, but not errno/syscall/path.
Comparator call sequences differ in sort and toSorted (stable insertion sort here, TimSort in V8 — sorted results are byte-identical for consistent comparators), and localeCompare compares code units, not ICU collation.
Dynamic-tier limits
- The island is quickjs-ng, not V8 — correct, but slower for CPU-bound dependency code. The win is startup, size, memory, deployment shape.
- The island's Node builtins are shims — reimplementations, reported per-builtin in the coverage report, not the real modules.
- Island microtask interleaving: static fibers drain first, then the engine's jobs at loop quiescence — a static
awaitracing a package promise resolves in a documented, deterministic order that can differ from Node's interleaving. - Top-level
awaitin embedded ESM packages is not supported yet. It does compile in your program's own ESM graph and in npm packages compiled through--npm-static; the remaining limit is package code running inside the--dynamicisland. --npm-staticand--provenance-sourcesare experimental — see npm Dependencies for the maturity notes.
WASI target limits
The production wasm32-wasi target supports the complete executable language tier through LLVM: async/await, promises, generators, timers and other portable event-loop work, stdin/readline, filesystem callbacks and promises, and the --dynamic island. Portable WASI Preview 1 has no socket, process-spawn, OS-signal, network-interface, or filesystem-notification capabilities, so networking/fetch, child processes, signal APIs, os.networkInterfaces(), and fs.watch are rejected before linking with SC3002. --sanitize, native FFI, and library-mode archive builds are also unavailable. Filesystem access is bounded by the host's preopens; scriptc run exposes the current working directory and /tmp. See Platform Support for build and run details.
Tooling gaps
scriptc rundoes not forward extra CLI arguments to the program —buildand invoke the binary directly.- Native FFI is a direct, manifest-declared C ABI link surface. Callback parameters are synchronous, call-scoped, and same-thread; retained or foreign-thread callbacks are not supported yet. Variadic calls, structs by value, owned pointer/string/byte returns, runtime dynamic-library loading, and library-mode builds also remain unsupported. See Native FFI.
- Numbers are JS-exact f64 everywhere. Integer inference and ownership analysis — the systems-language performance ceiling — are roadmap, not shipped.