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

  • var declarations, and loose equality ==/!= (they need dynamic coercion semantics; use let/const and ===).
  • Labeled break/continue, and jumps crossing a finally block (return through finally is supported).
  • Top-level await — wrap it in an async function main() and call it.
  • Generic classes, generic methods, and generic arrows — only top-level generic function declarations monomorphize.
  • Generic functions and stdlib methods used as values (const f = id, const g = Math.floor) — 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.length on string | 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 .join on 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[] — compiles
  • undefined/null exist only as union arms, not as standalone values; optional class fields are fenced (optional record fields and optional/default/rest parameters compile).

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.
  • Map keys and Set elements are strings and numbers; other key types are fenced.

The any/unknown boundary

  • any without --dynamic is a compile error (SC2011) — use unknown and a checked cast, or opt into the engine.
  • any and unknown ride locals, parameters, and returns — never class fields, array elements, or union arms (record and tuple fields hold unknown).
  • Operations on unknown beyond the supported surface (truthiness, typeof narrowing, 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.

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 shapeprocess.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 (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 await racing a package promise resolves in a documented, deterministic order that can differ from Node's interleaving.
  • --npm-static and --provenance-sources are experimental — see npm Dependencies for the maturity notes.
  • Cross-compiled --dynamic binaries are not supported yet — the engine archive is host-native today.

Tooling gaps

  • scriptc run does not forward extra CLI arguments to the program — build and invoke the binary directly.
  • Native FFI is a direct, manifest-declared C ABI link surface. It does not yet support callbacks, variadic calls, structs by value, owned pointer/string/byte returns, runtime dynamic-library loading, or library-mode builds. See Native FFI.
  • Numbers are JS-exact f64 everywhere. Integer inference and ownership analysis — the systems-language performance ceiling — are roadmap, not shipped.
  • No Windows servers or child_process yet — see Platform Support.