Morpher Internals
Part of: Dev Docs
Morpher is the crate that performs the headline trick: TypeScript in, C++ out, no interpreter, no garbage collector, behavior identical to Node.js. It powers both hats of the morph binary — direct file morphing (morph foo.ts --to cpp) and the component-logic translation inside GUI builds. This page is the contributor's cutting guide: the source layout, the public surface, the decision flow, and exactly where each kind of change goes.
For the what and why — the intent model, escape analysis in depth, the memory story — read Intent-Based Codegen first, then Escape Analysis. For the JS-semantics machinery (comparisons, strings, the Js* family), see JS Semantics. Here: the map.
Public surface (lib.rs, parser.rs, error.rs)
| Item | Role |
|---|---|
TranslateOptions { type_mode, runtime_path, indent } |
The knobs: TypeMode::{Strict, Infer}, where the runtime lives, indent width |
translate(source, filename, options) |
The front door: full program → C++ |
translate_default / translate_fragment / translate_snippet / translate_with_indent / SnippetOutput |
Entry points for partial translation (GUI logic emitter uses the fragment/snippet paths) |
translate_rust / translate_to_rust |
Experimental TS→Rust emission — not production, same story as the codegen-side intern |
translate_file_to_cpp(path) |
File-level convenience over translate |
MorphJsError (error.rs) |
The error enum: unsupported syntax, type resolution failures, with source positions |
parser.rs (translate_to_cpp, translate_snippet_to_cpp, translate_str) |
Oxc parsing + translator orchestration — parse once, analyze, emit |
linter.rs (check_str, check_with_strictness, Strictness) |
Pre-translation diagnostics: unsupported syntax rejected early with named errors instead of confident garbage |
Errors are values, not panics: translation failures come back as MorphJsError, and user-facing commands render them as prose. An unwrap() on user input anywhere in this path is a bug.
Source layout (crates/morpher/src/)
| File | Owns | Size (approx) |
|---|---|---|
codegen/analyzer.rs |
EscapeAnalyzer: escape/widen/usage/comparison collection, both fixpoints |
~2.9k lines |
codegen/cpp.rs |
CppTranslator: all emission — navigate by function, never by reading |
~4.5k lines |
codegen/context.rs |
Ctx: var_types, async_fns, escape_hints, shared_ptr_vars, fn_return_types, include-needs (need() / generate_includes()), indentation (indent(), sub(), merge()), depths, async_result_type() |
the notebook |
codegen/type_resolver.rs |
Annotation → C++ maps, Promise→Result denormalization, headers_for() include registration |
the dictionary |
codegen/string_methods.rs |
StringMethod + StringMethodHandler: JS name → morph::str::* |
the phrasebook |
codegen/js_comparison.rs |
OperandClass, ComparisonSignature, per-file morph::js_cmp block builder |
the etiquette guide |
codegen/rust.rs |
Experimental TS→Rust emission | not production |
codegen/mod.rs |
Module wiring | — |
The iron rule, repeated because it saves careers: analysis in analyzer.rs, emission in cpp.rs, never mixed. If your change needs a new fact about the code, collect it in the analyzer and read it in the emitter. Smuggling analysis into the emitter produces bugs that manifest three fixtures away. Ctx is the only sanctioned channel between the two — var_types for decisions, need() for includes (new headers must register via Ctx::need() / headers_for(), or generated files won't compile).
The pipeline inside the crate
source text
│ Oxc parse (parser.rs)
▼
AST + diagnostics
│ linter (check_str): reject the unsupported early
▼
EscapeAnalyzer: two passes to fixpoint
│ (escape kinds, widening map, usage, comparisons)
▼
CppTranslator + Ctx: emit
│ (base type → widen → sync-strip → storage → linkage)
▼
C++ source (includes via generate_includes)The analyzer runs to fixpoint because facts beget facts: learning that x escapes into a closure re-opens the closure's captures, which may reveal y escapes too. The loop exits when a full pass learns nothing — termination is guaranteed because each pass only adds facts from a finite set.
The decision flow per declaration
emit_typed_variable_declarator (codegen/cpp.rs) decides everything about a let/const in this order (worked examples in Escape Analysis):
- Base type — strict: annotation, else infer from init. Infer: always from init (
infer_type_from_init: literals →std::string/bool/int64_t/double; homogeneous arrays →vector<T>recursively;new Promise<T>→morph::Result<T>; class instantiation →shared_ptr<Class>; fallbackJsValue,autofor deducible expressions). - Widening — apply the analyzer's
widensmap (ToJsNumber/ToJsString/ToJsValue/ToJsArray), except a trusted native-number annotation beatsToJsNumber/ToJsValue, and proven-small integers refine toint32_t. - Sync strip —
Result<T>→Tfor plain sync call initializers (strip_result_for_sync_call). - Storage —
EscapeKind:None→ stack value;Return/Global→unique_ptr+make_uniquefor class/vector, stack copy for scalars;ClosureCapture/MultipleRefs/AsyncBoundary→shared_ptr+make_shared(scalar use sites dereference). - Linkage/decoration —
staticat file scope,const-fold forJs*scalars, record inctx.var_typesfor later references.
The aesthetic throughout: stay native when provable (std::string over JsString, int32_t over int64_t), spend JsValue only where JS semantics are genuinely needed. A JsValue baseline drags in <format> and roughly half a megabyte — the variant tax, paid only when owed.
How morpher serves the GUI pipeline
Morpher is not a rival of the GUI pipeline — it is the same brain in a second hat. The logic emitter leans on the fragment/snippet entry points to translate component logic (handlers, effects, reactive expressions), while morph <file> --to cpp translates whole files standalone. A morpher improvement (new method, better inference, fixed coercion) improves both consumers at once — and can break both at once, which is why the fixture suite covers both shapes (behavioral and structural; see Testing).
Common contributions, by file
| "I want to…" | Touch |
|---|---|
Support a new string method (e.g. padStart) |
JS name + helper mapping in string_methods.rs → helper in runtime/cpp/types/js_string_helpers.h → fixture case |
| Support a new array method natively | Check the array-method list in analyze_call_expression — listed methods force JsArray. Native lowering needs an emitter path in cpp.rs plus the same fixture treatment |
Add a node:*/global (e.g. Math) |
Analyzer: stop rejecting / classify the operand; emitter: lower to the C++ equivalent (<cmath>); morph check diagnostics updated in step — linter and emitter must never disagree about what exists |
| Fix a comparison coercion | js_comparison.rs — find the (OperandClass, OperandClass, op) cell, fix the helper section, check 21_js_comparison-style fixtures |
| Fix a type decision | infer_type_from_init for init-based, type_resolver.rs for annotation-based, widen rules in analyzer.rs for usage-based — know which of the three you mean before editing |
| Change pointer/storage selection | The escape-allocation block in the typed declarator — and re-run all fixtures, since this affects every declaration |
| Add an include for a new helper | Ctx::need() at the use site + headers_for() in type_resolver.rs — unregistered headers compile in no generated file |
| Reject new unsupported syntax early | linter.rs with a named diagnostic — the emitter should never see what the linter can't describe |
Testing a morpher change
cargo test --workspace # unit + translator tests
python -m pytest tests/translate/ -v # fixtures + intent testsThe translate suite does the real verification: each fixture .ts is translated, compiled with g++-14 -std=c++23, run, and its output diffed against Node.js. If your change alters any existing fixture's .cpp, inspect that diff line by line — it is the review. Fixture .cpp files are gitignored; only the .ts sources and expected outputs are reviewed.