Morph's translator runs intent-based codegen with compile-time escape analysis on every file — generating minimal, optimal C++ that matches what a human expert would write, without a garbage collector. There is no flag to enable; this is the only mode.
Most translators map syntax to syntax: let x = 5 becomes JsValue x = 5 because it might be anything. Morpher instead asks three questions about every variable and lets the answers pick the C++:
let x: int, const s: string)fetch? captured by a closure? _ recorded as UsageKind per use)await? — recorded as EscapeKind)Declared intent and observed intent are reconciled by widening (usage wins over annotation when they disagree), and lifetime intent picks the storage (stack, unique_ptr, or shared_ptr). When all three agree the value is a plain local integer, you get int32_t x = 5; — zero overhead, freed automatically. Nothing is boxed "just in case."
Traditional JS→C++ translators emit JsValue (a std::variant) for everything, heap-allocate all objects, and use shared_ptr everywhere. This works but adds massive overhead:
<format> for every TU)int, string, vectorTypeScript Source
│
▼
┌──────────────────┐
│ Oxc Parse │ → AST with type annotations
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Semantic Analyzer│ → Annotated AST
│ - Escape analysis│ EscapeKind (None/Return/Global/Closure/...)
│ - Type widening │ WidenedType (None/ToJsNumber/ToJsString/...)
│ - Async graph │ UsageKind (Arithmetic/DynamicAssign/ToString/...)
│ - Closure detect │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ C++ Emitter │ → Optimized C++ (no templates unless needed)
│ - Native types │ int32_t, std::string, std::vector on stack
│ - Smart pointers│ unique_ptr + move, shared_ptr only where required
│ - Coroutines │ morph::Result<T>, morph::Task
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Runtime Linker │ → Minimal includes (only what's used)
│ - Feature flags │
└──────────────────┘One function runs the whole analysis — EscapeAnalyzer::analyze_program (crates/morpher/src/codegen/analyzer.rs) — in seven phases, single pass plus fixpoints. No separate borrow checker, no IR round-trips; the Oxc AST is walked directly:
collect_signatures — every top-level function is recorded (FunctionSignature: name, async?, params, return type) and every file-scope variable is pre-marked EscapeKind::Global. Async functions and async arrow/function expressions assigned to variables join the async_functions set.analyze_statement (recursive walk) — each statement is visited: declarations create a VarInfo; return x marks Return; await x marks AsyncBoundary; x = y (identifier to identifier) marks both sides MultipleRefs; listed string methods keep natives native via helpers while listed non-string methods widen to JsString, array methods widen non-text receivers to JsArray, and any other method call records usage without widening; .length reads stay native; every comparison and every truthiness test (if/while/for conditions, &&/||, !, ternaries) records a ComparisonSignature for the js_cmp emitter; assignments fold observed integer ranges for int32_t proofs.detect_chaining — a second walk finds s.toUpperCase().toLowerCase() shapes (a member access whose object is itself a call) so chained receivers resolve to the base variable's domain instead of degrading to boxed calls.resolve_cross_function_escapes — fixpoint over function signatures: parameters of async functions that are awaited or co-returned inside get AsyncBoundary, since the coroutine frame will own them.resolve_identifier_init_classes — fixpoint over let alias = target chains (forward references included): an alias inherits its target's operand class, so let b = a; b + 1 stays arithmetic instead of falling back to JsValue."", each function under its own name, arrows and methods inheriting the enclosing scope): the last read offset of every variable, computed once with an exhaustive AST walk and handed to the emitter for moves.AnalysisResult — the maps (escapes, widens, var_infos) plus async_functions, comparison_signatures, closure_captures, narrow_splits (with per-variable definition/use sites for liveness), and last_read_spans (last read offset per variable per function, driving moves) are handed to the emitter, which makes every declaration decision from them (emit_typed_variable_declarator in crates/morpher/src/codegen/cpp.rs).pub struct VarInfo {
pub name: String,
pub annotated_type: Option<String>, // declared intent: `: int`, `: string`, ...
pub escape_kind: EscapeKind, // lifetime intent (max wins, see below)
pub widened_type: WidenedType, // observed intent: None/ToJsNumber/ToJsString/ToJsValue/ToJsArray
pub is_mutable: bool, // let vs const
pub usages: Vec<UsageKind>, // every observed use (24 kinds: ArithmeticOp,
// MethodCall, Awaited, Iterated, Spread, ...)
pub init_operand_class: OperandClass, // what the initializer's C++ value is
// (Integer/Float/Text/Boolean/JsValue/...)
pub int_range: Option<(i64, i64)>, // proven min/max over literal assigns
pub int_range_exact: bool, // false once anything dynamic touches it
pub def_sites: Vec<DefSite>, // every definition: statement index, region, value class, literal
pub use_sites: Vec<StmtSite>, // every read: statement index plus region
pub has_loop_use: bool, // any definition or read under a loop
pub decl_func_depth: usize, // function nesting of the declaration
}pub struct StmtSite {
pub index: usize, // statement order in the analysis walk
pub branch_depth: usize, // nesting inside branches
pub func_depth: usize, // function nesting
}
pub struct DefSite {
pub site: StmtSite,
pub value_class: OperandClass, // Integer, Float, Text, JsValue, ...
pub int_literal: Option<i64>, // proven literal, when the value is one
pub span_start: Option<u32>, // source offset, keys narrowing splits
}mark_escape never downgrades: escapes.insert(name, EscapeKind::max(current, kind)), with priority AsyncBoundary > ClosureCapture > MultipleRefs > Global > Return > None. A variable that is both returned and captured gets shared_ptr — the stricter need always wins.
For each variable, the analyzer determines why it escapes:
Does it escape the function?
│
├─► NO → Stack allocation (native type)
│ int x = 5;
│ std::string s = "hi";
│ std::vector<int> v = {1,2,3};
│
└─► YES → Why does it escape?
│
├─► Returned (single owner moves out)
│ → `unique_ptr` for class instances and vectors;
│ scalars and `Js*` values copy out on the stack
│ std::unique_ptr<User> createUser() { std::unique_ptr<User> u = std::make_unique<User>(); return u; }
│
├─► Stored in global/container (ownership transferred)
│ → file scope emits `static`; containers hold plain values and
│ `push` moves its argument at its last read
│ static JsArray SEEN = JsArray{}; SEEN.push(tag);
│
├─► Captured by closure (shared ownership)
│ → shared_ptr (use sites dereference scalars: `(*count)`)
│ auto count = std::make_shared<int64_t>(0); return [&, count](){ return (*count); };
│
├─► Multiple simultaneous references (shared mutable)
│ → shared_ptr — but only for heap-needy types (classes, vectors).
│ Value types copy soundly: `JsObject` / `JsArray` share storage
│ internally, so a plain copy still aliases.
│ let a = {x:1}; let b = a; b.x = 2; // both see change via JsObject
│
└─► Crosses async boundary (await/co_return)
→ shared_ptr (coroutine frame may outlive caller)AsyncBoundary > ClosureCapture > MultipleRefs > Global > Return > NoneHigher priority wins when a variable has multiple escape reasons.
| EscapeKind | C++ Type | Reason |
|---|---|---|
None |
T (stack) |
Zero overhead, auto cleanup |
Return / Global |
std::unique_ptr<T> + implicit move for class/vector; stack copy for scalars and Js* |
Heap only pays off where copies are deep or move-only |
ClosureCapture / AsyncBoundary |
std::shared_ptr<T> |
Shared ownership required; scalar use sites dereference ((*x)) |
MultipleRefs |
std::shared_ptr<T> for class/vector; plain copy for value types (JsObject/JsArray share storage internally) |
Heap only where a copy would break aliasing |
shared_ptr only where semantically required — never "just in case."
There is no garbage collector in emitted code — not a tracing one, not a reference-count-everything one. Memory is managed by a compile-time ownership decision per variable, executed by C++ RAII (destructors run deterministically at scope exit). This section is the full story: the three storages, what each costs, why sharing still needs shared_ptr, and where the Js* wrappers fit.
Every declaration lands in exactly one bucket:
| Storage | When | Allocation | Cleanup | Cost |
|---|---|---|---|---|
Stack value (int32_t x = 5;) |
EscapeKind::None — provably local |
None (register/stack slot) | Automatic at scope exit | Zero |
unique_ptr<T> + std::move |
Return / Global — single owner moves out |
One heap allocation (make_unique) |
Freed when the owner dies | One allocation, no counting |
shared_ptr<T> (make_shared) |
ClosureCapture / AsyncBoundary — genuinely shared; MultipleRefs only for class/vector (value types copy) |
One heap allocation (make_shared merges object + control block) |
Freed when the last owner dies | Atomic refcount inc/dec per copy |
const on a Js* scalar folds to const JsNumber etc., and file-scope declarations get a static prefix so they live for the program's duration instead of per-call.
A GC exists to answer "is this still reachable?" at runtime. Morpher answers it at compile time instead: the escape walk proves, per variable, whether the value can outlive its scope and whether it has one owner or many. When the proof says "local integer, never leaves," there is nothing left for a collector to do — generating a traced or counted box around it would be pure overhead (allocation + write barriers + collection pauses) protecting against a situation the analyzer already ruled out.
The honest corollary: the proof is conservative. Anything the analyzer can't see through ( genuinely dynamic values — fetch, JSON.parse, unannotated cross-module data) widens to a Js* wrapper, and those wrappers do share internally (next section). Safety is never sacrificed for speed; speed comes only from cases proven safe.
shared_ptr is still needed (and what it costs)Three JS semantics genuinely require shared ownership — there is no cheaper correct answer:
let b = a; b.x = 2 must be visible through a). The analyzer marks both sides MultipleRefs. For classes and vectors both names share one shared_ptr — one object, two owners, exactly like the JS heap. For JsObject / JsArray a plain copy already aliases (storage is shared internally), so no outer wrapper is added.return () => ++count). The lambda outlives the stack frame that created count, so the counter moves to the heap and both the (dead) frame's successor and the lambda hold a shared_ptr (ClosureCapture).await/co_return). A suspended coroutine's frame survives the caller's return, so locals it keeps must be heap-owned (AsyncBoundary).The cost is real and worth naming: each shared_ptr copy is an atomic increment, each destroy an atomic decrement, plus the control block allocation (which make_shared fuses with the object into one allocation — the emitter always uses make_shared, never bare new). Atomics are cheap next to a heap allocation but not free next to a stack slot — which is precisely why they're emitted only for the three cases above.
Js* wrappers fitWhen widening fires, the variable becomes a Js* type — and those are not bare values:
JsValue is a std::variant of 8 alternatives (JsUndefined, JsNull, JsBoolean, JsNumber, JsString, JsArray, JsObject, JsFunction — runtime/cpp/types/js_value.h:32). It costs sizeof(largest alternative) per value plus a tag check (dispatch) on every operation. Correct for anything, free for nothing.JsArray / JsObject share internally: elements is a shared_ptr<vector<JsValue>> ("shared_ptr for JS-like reference semantics (no deep copy on assignment)" — js_array.h:11), and properties likewise. Assigning one JsArray to another copies the pointer, not the data — JS aliasing semantics preserved, at one atomic count per copy.So the performance story is really a widening-avoidance story: every variable that stays native skips the variant size, the dispatch, and the refcounting. --type infer exists to maximize exactly that set.
Task, Result<T>, and the sync stripAsync functions become coroutines returning morph::Task (no value) or morph::Result<T> (a value) — the coroutine frame (locals, suspend state) is heap-allocated by the C++ runtime and freed when the coroutine completes. That's why AsyncBoundary forces shared_ptr: a value the frame keeps must outlive the caller that created it.
Two refinements keep this cheap:
new Promise<T> infers morph::Result<T> from the type argument at the declaration, so let p: Promise<number> = new Promise<number>(...) never touches JsValue.Result<T>, the emitter strips it to T (strip_result_for_sync_call) — no coroutine frame, no wrapper, just the value.Frames are reclaimed, not leaked: both Task and Result<T> destroy a completed frame on destruction (and release a completed frame on move-assignment), mirroring each other. Verified by allocation counting — 1000 Result lifecycles allocate and free 1000 frames. Fire-and-forget fetch is covered too: the network thread destroys the frame on completion when nobody awaits it (net.cpp, await_suspend). The remaining exception is a discarded user coroutine that suspends on anything else — with no owner and no detach primitive, that frame leaks.
std::string (small-string optimization: short strings never touch the heap) while JS methods are served by morph::str::* helpers that take and return std::string — chains nest (to_lower(to_upper(s))), so no intermediate JsString box is ever allocated. Only genuinely dynamic strings become heap-owning JsString.[1,2,3] → std::vector<int32_t>, [[1,2]] → std::vector<std::vector<int32_t>>, mixed or empty → std::vector<JsValue> fallback. Homogeneous data gets contiguous native storage and cache-friendly iteration; only heterogeneous data pays for the variant per element.File-scope variables start as EscapeKind::Global and emit as static (unique_ptr for objects). They live until program exit — matching JS module-scope semantics, where top-level bindings never die. Side-effectful initializers (static auto x = f();) are additionally moved into main() in source order so they run exactly when JS would run them (edge case 8 above) — lifetime and execution order are both preserved.
let x = 5;
x = await fetchBigNumber(); // Could overflow int64, or be stringThe analyzer widens the type based on usage, not just annotation. Widening always applies — there is no flag:
| Annotation | Only Arithmetic | Assigned from Dynamic | String/Number Method Called |
|---|---|---|---|
int / int32 / int64 |
int32_t / int64_t (proven range) |
trusted annotation, else JsNumber |
native + morph::str::* helper (to_string, charAt, …) |
float / double |
float / double |
trusted annotation, else JsNumber |
native + morph::str::* helper |
string |
std::string |
JsString |
s stays native, calls nest: to_lower(to_upper(s)) |
number |
int32_t / JsNumber |
JsNumber |
x.as_string() |
T[] with .push() / array methods |
std::vector<T> |
JsArray |
method decides: push_back vs push, .size() for .length reads |
int32_t when every assigned literal fits, else int64_t; double stays double)parseInt, widened vars, x = await … reassignments) → JsNumberawait declarations deduce (auto x = co_await …) — the coroutine type is known, so no boxingmorph::str::* helper (runtime/cpp/types/js_string_helpers.h); chains nest so no intermediate boxes. Listed-but-not-string methods (toFixed, toLocaleString, …) widen the receiver to JsString instead — and those calls are currently unimplemented (see edge 5). Array methods widen non-text receivers to JsArray. Any other method call records usage without widening..length reads never widen — the emitter lowers them to .size() for vectors, strings, arrays, and JsValue alike; only an actual .length() call on a non-string widens.push(), .map(), …) → JsArray, except on proven strings (slice/indexOf/includes exist on both)JsValueA native number annotation on a proven-unknown future is a promise the emitter honors — even in --type infer, which otherwise ignores annotations:
let userLimit: int = await fetchLimit(); // unknown future, user knows the boundint userLimit = (std::get<JsNumber>(JsValue(co_await fetchLimit()).inner)).as_int();Trust fires only when the compiler proved the future unknown (dynamic widening, no initializer, or an await boundary). Statically known values keep inferred types, and proven non-numeric usage (JsArray, unknown methods) still widens. Assignments into trusted variables convert the same way.
Widening is not one-way. When a wide variable is reassigned with a proven integer literal on a straight-line path — every definition and use at branch depth zero in the same function, no loop or closure capture in play — the reassignment redeclares the variable under a fresh native name from that point on:
let tally = await fetchCount(); // unknown future: wide
console.log(tally);
tally = 42; // proven int32 literal, straight line
console.log(tally);auto tally = co_await fetchCount();
std::println("{}", tally);
int32_t tally_narrowed_1 = 42;
std::println("{}", tally_narrowed_1);Later reads and compound assignments (tally += 1) use the narrowed name; a subsequent non-literal assignment drops back to the wide name. Anything that breaks the straight line — a branch, a loop, a capture, a compound += at the split point — keeps the wide type. The rule is deliberately narrow: one literal, one region, provably safe.
The modes differ in exactly one place — the base_type computation at the top of the single declarator (emit_typed_variable_declarator):
--type strict --type infer (default)
│ │
▼ ▼
Has annotation? ──yes──► use it Always infer from the
│ │ initializer, ignore the
no │ annotation entirely
│ │ (except trusted numbers)
▼ ▼
infer from init ◄──┴──► (same inference)
│ │
▼ ▼
Apply widening table Apply widening table
above aboveConsequences: in strict mode let x: number = 5 is JsNumber (you asked for the general type, you get it); in infer mode it's int32_t (the initializer is all the evidence there is). Parameters follow the same split — strict classifies them from their annotations, infer leaves them as auto, deduced from the argument at each call site. Widening from dynamic sources still fires in both modes: reality beats declarations everywhere, unless a trusted annotation claims the range.
Native types are fast but answer comparisons differently than JavaScript ("" == 0 is a compile error in C++, true in JS). So the analyzer records a ComparisonSignature for every comparison and truthiness test, and the emitter generates a morph::js_cmp helper block with exactly the sections that file uses:
let label: string = "";
let count: number = 0;
console.log(label == count); // true — both are falsystd::println("{}", morph::js_cmp::loose_eq(label, count));int64_t == int64_t already matches JS).=== is decided inside the helper (a constexpr false for mismatched types); the emitter always calls morph::js_cmp::strict_eq and never folds at the call site.| TS Pattern | Human Intent | C++ Translation |
|---|---|---|
let x: int = 5 |
Native integer, known small | int32_t x = 5; |
let x = 5 (only +, -, * used) |
Native integer | int32_t x = 5; (inferred) |
let x = 3000000000 |
Native integer, big | int64_t x = 3000000000; |
let x: int = await f() |
Native integer, unknown future | int x = (…).as_int(); (trusted) |
let s = "hello" |
String value | std::string s = "hello"; |
let a = [1,2,3] + for (x of a) |
Iterable sequence | std::vector<int32_t> a = {1,2,3}; |
let o = {a:1} + o.a |
Map-backed dynamic object | JsObject o = JsObject{{"a", 1}}; then o["a"] (no anonymous structs are emitted) |
async function f() { await g() } |
Coroutine | morph::Task f() { co_await g(); } (morph::Result<T> when it returns a value) |
let r = await fetch() |
Async I/O | auto r = co_await morph::net::fetch(url); |
fetch() without await |
Discarded call (no detach primitive) | morph::net::fetch(url, data); — the network thread reclaims the frame |
class C { method() {} } |
Object with methods | class C { void method(); } + shared_ptr<C> at new sites (methods are never virtual) |
interface I { x: number } |
Abstract contract | class I { virtual int getX() = 0; } |
Promise.all([...]) |
Parallel wait | Not implemented — passed through as Promise.all(...) and rejected by the C++ compiler |
Top-level await |
Program entry | int main() running the main coroutine to completion via a process_tasks() pump |
const user = { name: "Alice" };
const admin = user;
admin.name = "Bob";
console.log(user.name); // "Bob"Detection: Variable assigned to another + mutation through either
Translation: plain JsObject copies — no shared_ptr wrapper, because JsObject shares its property storage internally:
JsObject user = JsObject{{"name", "Alice"}};
JsObject admin = user; // shares storage internally, refcount=2
(admin["name"] = "Bob");
std::println("{}", user["name"]); // "Bob"(Declared classes are the case that truly heap-shares: new C() gives shared_ptr<C>, and an alias copies the handle.)
function makeCounter() {
let count = 0;
return () => {
count = count + 1;
return count;
};
}Detection: Variable used in nested function after parent returns
Translation: shared_ptr<int> captured by value into the lambda
auto makeCounter() {
auto count = std::make_shared<int64_t>(0);
auto bump = [&, count]() -> JsNumber { ((*count) = (*count) + 1); return (*count); };
return bump;
}File-scope lambdas stay [] (namespace scope forbids captures); parameters shadowing an outer name are never captured.
class Cache {
warm: boolean = false;
}
async function main(): Promise<void> {
const cache = new Cache();
cache.warm = true;
await tick(); // suspension: the frame must keep `cache`
console.log(cache.warm); // true
}Detection: A heap-owned local (shared_ptr here) is still in use after an await suspension point
Translation: the coroutine frame keeps the shared handle, so the value outlives the suspension:
std::shared_ptr<Cache> cache = std::make_shared<Cache>();
(cache->warm = true);
co_await tick();
std::println("{}", cache->warm);Async value-returning functions themselves return morph::Result<T> (Promise<void> gives morph::Task); await on a call deduces auto with no boxing. Two shapes do not work yet: returning a class instance out of an async function (the unique_ptr→Result<T> conversion has no bridge), and member access on an awaited-and-unwrapped class value.
let x = 5;
x = await fetchBigNumber(); // Could overflow int64, or be stringDetection: reassignment from an await boundary (declarations deduce await directly via auto)
Translation: widen to JsNumber at the declaration (handles int64, double, bigint, string)
JsNumber x = 5;
x = co_await fetchBigNumber(); // JsNumber handles overflow/bigintWith a native number annotation the promise wins instead — see Trusted Annotations.
.toString() Calllet x: int = 42;
console.log(x.toString());Detection: .toString() on native
Translation: Emit morph::str::to_string(x)
int64_t x = 42;
std::println("{}", morph::str::to_string(x));(.toFixed() / .toPrecision() are still unimplemented — on natives and wrappers alike.)
let s: string = "hello world";
console.log(s.toUpperCase().toLowerCase());
console.log(n.toString().charAt(0).toUpperCase() + n.toString().slice(1));Detection: a method call whose receiver is itself a method call (CallExpression as StaticMemberExpression object), plus computed receivers like arr[0] or split(t, ",")[1] — resolved recursively to the base variable's domain
Translation: nest the helpers so every step stays std::string:
std::println("{}", morph::str::to_lower(morph::str::to_upper(s)));
std::println("{}", morph::str::to_upper(morph::str::char_at(morph::str::to_string(n), 0)) + morph::str::slice(morph::str::to_string(n), 1));JsString / JsValue receivers keep their direct methods (obj["name"].toUpperCase() works because JsValue forwards them) — only native receivers go through helpers.
new Promise<T> Infers morph::Result<T>let p2: Promise<number> = new Promise<number>((resolve) => { resolve(42); });Detection: NewExpression with callee Promise (type argument read the same way emit_new reads it)
Translation: the variable infers morph::Result<JsNumber> even in --type infer, so the later p2 = morph::Result<JsNumber>::resolved(42) assignment type-checks; Promise<void> infers morph::Task.
let p4: Promise<void> = voidPromise(); // logs "void" as a side effect
console.log(p1);Detection: file-scope static auto x = f(); with a call initializer, where x is never referenced inside any function/class body (fixpoint check over word-boundary references)
Translation: the declaration moves into main() as a local, in source order — auto p4 = voidPromise(); runs exactly where JS would run it. Variables used by other functions stay at file scope (previous behavior).
#include "/home/user/.morph/cache/runtimes/cpp/v0.1.0/types/js_types.h"Detection: every ../../runtime/cpp/... header collected during emission
Translation: rewritten to the absolute runtime path (TranslateOptions.runtime_path, auto-detected from the global cache or local runtime/cpp) so the file compiles from any directory. js_value_format.h (vector/optional formatters) is pulled in automatically when std::vector meets println/format, so console.log([1,2,3]) prints [ 1, 2, 3 ] exactly like Node.
const SEEN: string[] = [];
function note(tag: string): void {
SEEN.push(tag);
}Detection: File-scope container mutated from a function
Translation: file scope emits static; the container holds plain values and push moves its argument at its last read:
static JsArray SEEN = JsArray{};
void note(auto tag)
{
SEEN.push(tag);
}Limits, stated plainly: there are no vector<unique_ptr<T>> element containers and no unique_ptr parameters (params are auto/bare), an empty-array annotation is ignored in infer mode (hence JsArray above, not vector<string>), and TS identifiers colliding with C++ keywords (e.g. a function named register) are not sanitized — that input is rejected by the C++ compiler. Class instances pushed into JsArray containers do not convert today.
fetch("https://example.com/ping");Detection: await not used on a promise-returning call
Translation: the call is emitted bare and its result discarded — there is no detach primitive (morph::net::fetch takes only a URL; an options object has no overload):
morph::net::fetch("https://example.com/ping");morph::net::fetch("https://example.com/ping");The eager part of the call still runs. But a call that actually suspends has no owner for its coroutine frame, so it leaks; fire-and-forget of suspending work stays unsupported until a detached-spawn primitive exists.
| Feature | Naive | Intent-based |
|---|---|---|
Template literal `Hi ${x}` |
<format> + std::format |
<format> + std::format ✓ |
console.log("Hi", x) |
<format> + std::format |
<print> + std::println("Hi {}", x) ✗ |
console.log(x) |
<format> + std::format |
<print> + std::println("{}", x) ✗ |
Rule: <format> (whose instantiation dominates compile time — see Measurements below) only for ${} template vars with two or more parts. console.log uses std::println directly, including the single-argument template case.
The emitter tracks exactly what's used:
// Analyzer tracks usage:
needs_vector → #include <vector>
needs_string → #include <string>
needs_coroutine → #include <coroutine>, "task.h"
needs_http → #include "net.h"
needs_format → #include <format> // ONLY for template literalsNo blanket js_types.h unless a Js* type is actually emitted — with one exception: pulling in the string helpers alone also pulls js_types.h, since the helpers share its basic types.
| Metric | Boxed-everything baseline | Intent-based (this mode) |
|---|---|---|
| Binary size (simple logic) | ~500 KB | ~150 KB |
| Compile time (logic.ts) | ~1.5s | ~0.5s |
| Runtime overhead (primitives) | Variant + heap | Zero (stack) |
int arithmetic |
JsNumber variant |
Native int32_t / int64_t |
| String concat | JsString heap |
std::string SSO |
| Vector push | JsArray refcount |
std::vector native |
Measured September 2026 (g++-14 -std=c++23 -O2, this repo's runtime):
| File | Binary | Compile |
|---|---|---|
07_operators.ts (small logic) |
188 KB (163 KB stripped) | 22 s |
11_complex.ts (105 lines, heaviest fixture) |
220 KB | 20 s |
Binary size lands near target; the remaining ~60 KB over it is JsValue's variant alternatives and coroutine frames, which shrink as more values stay native. Compile time does not meet target, and the cause is identified, not structural: preprocessing is 0.4 s, but instantiating the standard <print>/<format> machinery costs ~20 s on GCC 14 (compiling the same file with <print> removed takes 2.4 s). That cost is fixed per translation unit — it barely moves between the small and the heavy fixture — and it is paid once no matter how optimal the emitted code is. Reducing it means either a lighter print path or precompiled headers, both tracked separately from codegen quality.
# Direct file morph (intent-based codegen is the only mode)
morph app.ts --to cpp
# Type mode is orthogonal: infer (default) or strict annotations
morph app.ts --to cpp --type infer
morph app.ts --to cpp --type strict
# In a project (add to morph.config.json build flags)
# Not yet exposed — currently only for direct file morph| Feature | Status |
|---|---|
| Escape analysis (None/Return/Global/Closure/MultipleRefs/AsyncBoundary) | ✅ Built & integrated (crates/morpher/src/codegen/analyzer.rs) |
| Type widening (ToJsNumber/ToJsString/ToJsValue/ToJsArray) + chaining detection | ✅ (only genuinely dynamic usage widens) |
Integer-range proofs (int32_t when every value fits) + bounded loop counters |
✅ |
| Trusted native annotations on unknown futures | ✅ |
Scalar dereference at shared_ptr use sites |
✅ |
--type infer (default) / --type strict |
✅ (TypeMode in context.rs, --type CLI flag) |
Native string methods via morph::str::* helpers, chains nest |
✅ (string_methods.rs + js_string_helpers.h) |
JS comparison helpers (morph::js_cmp, only what's used) |
✅ |
Native type emission (int32_t, std::string, std::vector, recursive literals) |
✅ |
Smart pointer selection (unique_ptr/shared_ptr/stack) |
✅ (heap only for class/vector escapes) |
Promise<T> → morph::Result<T>, Promise<void> → morph::Task, new Promise<T> inference |
✅ |
Sync Result<T> strip to T when no co_await |
✅ |
Top-level await → async main wrapper; side-effectful static auto moved into main order-safely |
✅ |
Global absolute runtime includes; auto js_value_format.h for printed vectors |
✅ |
| All 29 translate fixtures passing (outputs match Node.js) | ✅ |
Returned class instances move out as unique_ptr (make_unique, implicit move on return); shared factory results wrap once in shared_ptr |
✅ |
Escaping lambdas capture shared state by value ([&, count] with deref'd body) |
✅ |
| Last-use moves at call args, assignment sources, and returns (span-anchored scans; plain writes never veto) | ✅ |
Aliased value types (strings, Js*) copy instead of heap-wrapping; heap stays for classes/vectors and lifetime extension |
✅ |
Destructuring declarations bind each name (auto per element; literals inline values; defaults/rest/computed keep the comment fallback) |
✅ |
Result<T> coroutine frames destroyed on completion (no per-call leak; verified 1000/1000 freed) |
✅ |
JsObject::clear() / JsArray::clear() break reference cycles manually (cycles documented as the no-GC limit) |
✅ |
| Last-read spans owned by the analyzer (one map per function); emitter moves consume them, no parallel scan | ✅ |
| File | Role |
|---|---|
crates/morpher/src/codegen/analyzer.rs |
EscapeAnalyzer, EscapeKind, WidenedType (+ToJsArray), UsageKind, chaining detection, AnalysisResult |
crates/morpher/src/codegen/js_comparison.rs |
OperandClass, ComparisonSignature, morph::js_cmp header builder |
crates/morpher/src/codegen/string_methods.rs |
StringMethod, StringMethodHandler — JS→morph::str::* mapping |
crates/morpher/src/codegen/cpp.rs |
emit_typed_variable_declarator, select_integer_type, trusted annotations, strip_result_for_sync_call, emit_new (Promise→Result), str_helper_decision, recursive vector literals |
crates/morpher/src/codegen/type_resolver.rs |
Native type maps, Promise→Result, denormalization |
crates/morpher/src/codegen/context.rs |
Ctx with var_types, async_fns, TypeMode, runtime_path |
crates/morpher/src/lib.rs |
TranslateOptions { type_mode, runtime_path, indent } |
crates/morphc/src/commands/translate.rs |
--type flag, wrap_top_level_in_main, safe static auto move |
runtime/cpp/types/js_string_helpers.h |
namespace morph::str — native string method equivalents |
tally_narrowed_1).unique_ptr, which would pessimize every element access with indirection. See Why locals never get `unique_ptr` below.[&, count]).Shared ownership cannot collect cycles — a.self = a keeps storage alive forever, exactly like Rc cycles in Rust or strong cycles in Swift. There is no cycle detector and no weak_ptr anywhere in the runtime, by design: tracing cycles would be a garbage collector. The contract is explicit:
JsObject::clear() and JsArray::clear() drop every edge the container holds, reclaiming the cycle (verified by allocation counting — a self-referential object leaks without it, frees fully with it).arr.length = 0 lowering to clear()) is open future work.unique_ptrA non-escaping std::vector or large object stays a stack value even though copying it is expensive, for three reasons:
v[i] becomes v->at(i)-shaped code with aliasing the optimizer cannot see through), while a move at the last use costs exactly one transfer and zero per-access overhead.unique_ptr cannot be shared: a second read, a capture, or a branch would need to convert back, adding control flow the analyzer would have to prove safe at every site.unique_ptr, shared factory results wrap once in shared_ptr, and last-use moves transfer vectors, strings, and shared handles at their final read.Heap is for ownership (one vs many owners, lifetime past the frame) — never for size.
int/float/std_string annotations