Elvelt

Backends

Special Lowering Patterns

Special Lowering Patterns

This document covers the bespoke lowerings the C++ backend applies to language constructs that don't have a one-to-one C++ analog. Each section explains the source-language construct, the IR form, and the C++ output.

Pattern File:line Source construct
JS destructuring (const {a, b} = obj) CppBackend.h:5549-5576 VariableDeclNode with pattern
Optional chaining (obj?.prop) CppBackend.h:8000-8006 IndexAccessNode with metadata["optional"] == "true"
Nullish coalescing (a ?? b) CppBackend.h:8134-8138 BinaryOpNode { op: "??" }
BigInt literals (123n) CppBackend.h:8480-8482 FunctionCallNode { name: "__bigint__" }
Tagged template literals CppBackend.h:8376-8393 FunctionCallNode { metadata["tagged_template"] == "true" }
Spread (...args) CppBackend.h:8217, 8026-8033 UnaryOpNode { op: "..." }
Destructuring source synthetic CppBackend.h:2592-2596 VariableDeclNode whose name starts with __mpl_destr_src_
new Constructor(...) CppBackend.h:8491-8630 FunctionCallNode with metadata["from_new"] or name == "new"
Proxy traps CppBackend.h:4050, 8483-8485 FunctionCallNode { name: "Proxy" }
super(...) / super.method() CppBackend.h:8316-8343 FunctionCallNode { name: "super" }, __mpl_super_*
await / yield CppBackend.h:~8900, ~8920 AwaitNode, YieldNode
JS class hoisting CppBackend.h:5009-5019, 4990-5007 ClassDeclNode with metadata["js.class"] == "true"
WebSocket SNI plumbing (runtime header; referenced in generated code) __mpl_ws_*
host_handle_ref / host_handle_unref (runtime header; referenced in generated code) runtime contract helpers

JS destructuring

File: CppBackend.h:5549-5576

Array destructuring

const [a, b, c] = [1, 2, 3];

The frontend produces a VariableDeclNode with pattern.kind == Array. The backend emits:

mpl_value __mpl_destr_tmp_1 = mpl_box_value(
    std::vector<mpl_value>{
        mpl_box_value(static_cast<long double>(1)),
        mpl_box_value(static_cast<long double>(2)),
        mpl_box_value(static_cast<long double>(3)),
    }
);
mpl_value a = mpl_get(__mpl_destr_tmp_1, 0);
mpl_value b = mpl_get(__mpl_destr_tmp_1, 1);
mpl_value c = mpl_get(__mpl_destr_tmp_1, 2);

(Where mpl_get(value, idx) is the variant-aware getter; mpl_get_value is the property-key version — see ./variant-handling.md.)

Object destructuring

const { a, b: renamed, c = 42 } = obj;

The IR has pattern.kind == Object, pattern.elements[i].sourceKey (the original key), pattern.elements[i].targetName (the new name), pattern.elements[i].defaultValue (optional default).

mpl_value __mpl_destr_tmp_2 = mpl_box_value(<expression-for-obj>);
mpl_value a          = mpl_get(__mpl_destr_tmp_2, "a");
mpl_value renamed    = mpl_get(__mpl_destr_tmp_2, "b");
mpl_value c          = (mpl_is_nullish(mpl_get(__mpl_destr_tmp_2, "c"))
                        ? mpl_box_value(static_cast<long double>(42))
                        : mpl_get(__mpl_destr_tmp_2, "c"));

The default-value path uses mpl_is_nullish (defined CppBackend.h:366-369) — undefined and null both trigger the default. This matches JS semantics: { c = 42 } defaults when c === undefined, not when c === null.

Destructuring in a for head

for (const { x, y } of pairs) { … }

The frontend emits a synthetic __mpl_destr_src_<n> VariableDeclNode whose initializer is the iterable. collectFunctionScopedVars recognizes the __mpl_destr_src_ prefix and skips it from hoisting (line 2592-2596), while still visiting the initializer for nested declarations.


Optional chaining (?.)

File: CppBackend.h:8000-8006

const city = user?.address?.city;

The frontend lowers ?. to an IndexAccessNode (or MemberAccessNode) with metadata["optional"] == "true". The backend emits:

auto __tmp = mpl_box_value(user);                                  // mpl_value outer
auto __expr = mpl_get_value(__tmp, "address");                     // mpl_value inner
const auto city = mpl_is_nullish(mpl_get_value(__tmp, "address"))
    ? mpl_box_value(__mpl_undefined__)
    : mpl_box_value(mpl_get_value(mpl_get_value(__tmp, "address"), "city"));

The implementation uses a fresh __mpl_opt_base_<n> variable (where <n> comes from switchCounter_) so nested optionals don't shadow each other.

Mixed optional chains

arr?.[0]

Becomes:

([&]() -> mpl_value {
    auto&& __mpl_opt_base_X = (arr);
    return mpl_is_nullish(__mpl_opt_base_X)
        ? mpl_box_value(__mpl_undefined__)
        : mpl_box_value(mpl_get_value(__mpl_opt_base_X, mpl_box_value(static_cast<long double>(0))));
})()

Notice the double-wrapping — the index is itself boxed for the mpl_get_value call.

Negated optional index

arr?.[0]

The ?. followed by an index-access produces:

auto __idx = mpl_box_value(<index-expr>);
auto __tmp = mpl_box_value(<base-expr>);
return mpl_is_nullish(__tmp)
    ? mpl_box_value(__mpl_undefined__)
    : mpl_box_value(mpl_get_value(__tmp, __idx));

Nullish coalescing (??)

File: CppBackend.h:8134-8138

const port = config.port ?? 8080;
([&]() -> mpl_value {
    auto&& __mpl_lhs_X = (mpl_box_value(config.port));
    return mpl_is_nullish(__mpl_lhs_X)
        ? mpl_box_value(mpl_box_value(8080))
        : mpl_box_value(__mpl_lhs_X);
})()

Both sides are boxed. The result preserves the original LHS type (string vs number vs object) — only the fallback is unconditionally evaluated only when needed (lazy).


BigInt literals

File: CppBackend.h:8480-8482

const big = 123456789012345678901234567890n;

The frontend lowers BigInt literals to a call to __bigint__ with the digit string. The backend bypasses mpl_call (which would try to treat __bigint__ as a JS callable object) and invokes the C++ helper directly:

mpl_value __mpl_destr_tmp_X = mpl_box_value(__bigint__(mpl_to_string(mpl_box_value(std::string("123456789012345678901234567890")))));

The double box (mpl_box_value(mpl_box_value(...))) is necessary because mpl_to_string(mpl_box_value(...)) flattens one layer, and the result needs to round-trip through the variant cleanly.

__bigint__ is defined in MPL_Standard/System/empl.h to call boost::multiprecision::cpp_int::cpp_int("…").


Tagged template literals

File: CppBackend.h:8376-8393

const result = html`<p>${name}</p>`;

The frontend splits tagged-template calls into a sequence of positional args. The metadata["tagged_template"] == "true" flag tells the backend to evaluate each argument once (in case it has side effects) before passing them to the tag function:

([&](){
    auto&& __mpl_arg_X_0 = (mpl_box_value(<cooked-strings-array>));
    auto&& __mpl_arg_X_1 = (mpl_box_value(name));
    return html(__mpl_arg_X_0, __mpl_arg_X_1);
})()

The lambda wrapper is what guarantees each argument is evaluated exactly once.


Spread operator (...)

File: CppBackend.h:8217, 8026-8033, 8072-8092

Spread in array literal

const x = [1, ...rest, 4];

The frontend marks the spread element as UnaryOpNode { op: "...", operand: <iterable> }. The backend detects the spread inside ArrayLiteralNode and switches to a boxed vector lambda:

([&]() {
    std::vector<mpl_value> __arr;
    __arr.push_back(mpl_box_value(1));
    for (const auto& __v : __mpl_for_of__(rest)) {
        __arr.push_back(mpl_box_value(__v));
    }
    __arr.push_back(mpl_box_value(4));
    return __arr;
})()

__mpl_for_of__ is the runtime helper that adapts any iterable (array, Map, Set, generator) to a C++ range-for.

Spread in function call

foo(...args, 5);

The backend emits:

__mpl_for_of_call__(foo, mpl_call_arg_spread(args), mpl_box_value(5))

(Implementation detail: spread in call position goes through the runtime's __mpl_for_of_call__ which packs the spread elements into the call argument vector.)

Spread as expression

const x = ...obj;        // not valid syntax, but: const x = obj;  (no spread)

The backend's UnaryOpNode { op: "..." } handler simply returns the operand expression (line 8217-8219) — this is a defensive default for IR nodes where the operand has already been destructured by the frontend.


new Constructor(...)

File: CppBackend.h:8491-8630

JS new is lowered two ways depending on which frontend form is used:

metadata["from_new"] == "true" form

const x = new Foo(1, 2);
([&]() -> mpl_value {
    auto&& __new_ctor_X = (Foo);                       // lookup the constructor
    auto __new_inst_X = std::make_shared<mpl_object>(); // allocate the instance
    mpl_copy_prototype(__new_inst_X,
        mpl_get_value(__new_ctor_X, "prototype"));       // set up prototype chain
    auto __new_boxed_X = mpl_box_value(__new_inst_X);
    auto __prev_this_X = mpl_new_this_ptr();            // save previous `this`
    mpl_new_this_ptr() = &__new_boxed_X;                // install new `this`
    auto __new_ret_X = mpl_box_value(mpl_call(__new_ctor_X,
        mpl_box_value(1), mpl_box_value(2)));
    mpl_new_this_ptr() = __prev_this_X;                 // restore previous `this`
    // JS semantics: if constructor returns an object, use it instead of `this`
    if (auto* __p_X = std::get_if<std::shared_ptr<mpl_object>>(&__new_ret_X)) {
        if (*__p_X) return __new_ret_X;
    }
    return __new_boxed_X;
})()

The __new_boxed_X allocation and prototype-copy are what makes new Foo() produce a real mpl_object with __proto__ pointing at Foo.prototype. The mpl_new_this_ptr() save/restore is what lets nested new calls work correctly (line 8582, 8617).

FunctionCallNode { name: "new" } form

Some frontends lower new X(...) to FunctionCallNode { name: "new", arguments: [X, …args] }. The backend handles this identically (line 8598-8630), but pulls the constructor out of arguments[0] instead of from call->name.

Special case: new Date(...)

const d = new Date(2024, 0, 1);

The backend skips the prototype-copy ceremony and uses mpl_call(Date, …) directly (line 8493-8500). Date is one of the few JS globals whose constructor is implemented in the runtime as a value-producing function rather than a class with a prototype.


Proxy traps

File: CppBackend.h:4050, 8483-8485

const p = new Proxy(target, {
    get(target, key) { return target[key] * 2; },
    set(target, key, value) { target[key] = value; return true; }
});

The frontend lowers this to a FunctionCallNode { name: "Proxy", arguments: [target, handler] }. The backend calls:

__Proxy_create__(target, handler)

__Proxy_create__ (defined in the runtime) installs the traps. The trap wrapper (line 4050, proxyTrapWrapperExpr) generates the correct arity for each trap type (get/has/set/defineProperty/etc.):

Trap Arity Wrapper
get 3 [&](auto target, auto key, auto recv) -> mpl_value { return handler_get(target, key, recv); }
set 4 [&](auto target, auto key, auto value, auto recv) -> mpl_value { return handler_set(target, key, value, recv); }
has 2 [&](auto target, auto key) -> mpl_value { return handler_has(target, key); }
deleteProperty 2 [&](auto target, auto key) -> mpl_value { return handler_delete(target, key); }

super(...) / super.method(...)

File: CppBackend.h:8316-8343

super(...) in a class constructor

class B extends A {
    constructor(x) {
        super(x);
        this.y = 0;
    }
}

The backend emits:

__mpl_super_call__(A, __this, mpl_box_value(x));
this->y = mpl_box_value(0);

__mpl_super_call__ invokes the parent class's constructor with the current __this and the forwarded args. The super() call must happen before any this.x = … access, which the JS frontend enforces via static analysis (the IR emits super() first in the constructor body).

super.method(...)

class B extends A {
    greet() { return super.greet() + " B"; }
}

Becomes:

mpl_value B::greet() {
    auto __super_ret = __mpl_super_method_call__(A, __this, std::string("greet"));
    return mpl_box_value(mpl_add(__super_ret, mpl_box_value(std::string(" B"))));
}

__mpl_super_method_call__ looks up the method on the parent class's prototype and invokes it with __this as the receiver.

super.prop (read)

class B extends A { whoami() { return super.name; } }

Becomes:

mpl_value B::whoami() {
    return __mpl_super_get__(A, __this, std::string("name"));
}

await / yield

File: CppBackend.h:~8900, ~8920 (referenced in dispatch tables)

await expr

const result = await fetch(url);
mpl_await(fetch(url));

mpl_await (preamble line 129-176) is overloaded for two cases:

Argument type Behavior
mpl_promise<T> Drains microtask queue, returns p.get()
Any other type Returns the value unchanged (synchronous passthrough)

The microtask queue (mpl_run_microtasks, line 114-121) ensures that other async work scheduled during the await body gets to run before the result is read.

yield expr (in a generator)

function* gen() {
    yield 1;
    yield 2;
    yield 3;
}

Generators are lowered to an mpl_generator<T> (preamble line 137-159). The body becomes:

mpl_generator<long double> __gen;
__gen.yield(static_cast<long double>(1));
__gen.yield(static_cast<long double>(2));
__gen.yield(static_cast<long double>(3));
return __gen;

The generator holds yielded values in a std::vector<T>. mpl_yield(gen, value) (preamble line 161-164) is the yield-statement macro — it both records the value and returns it for the surrounding expression context.

mpl_yield (preamble line 172-176) is also overloaded for non-generator contexts to drain the microtask queue — this is what makes yield valid outside of generator functions (it then behaves like await).


JS class hoisting

File: CppBackend.h:4990-5019, 4526-4577

JS class declarations have a hoisting quirk: class X { … } is hoisted but in the temporal dead zone until evaluation. To simulate this in C++, the backend:

  1. Detects ClassDeclNode with metadata["js.class"] == "true" (line 4982-4986).
  2. Emits a static mpl_value __mpl_js_class_value_X = __mpl_undefined__; slot at module scope (line 5004-5005).
  3. Emits the class body as a constructor lambda that assigns to the slot.
  4. References to X elsewhere in the module resolve to __mpl_js_class_value_X (line 7886-7888).

The visible-class stack (visibleJsClassNamesStack_) tracks which class names are accessible at the current scope, mirroring JS lexical scoping.

Class expressions

const X = class { greet() { return "hi"; } };

Class expressions are emitted into the same __mpl_js_class_value_X slot — but with isLoweredClassExprCtorAliasNode (line 3990) returning true, which suppresses the default [=] capture for the constructor lambda. This is because the class may reference itself in its constructor body, and [=] would attempt to copy the uninitialized slot.


WebSocket SNI plumbing

WebSocket support is exposed via runtime helpers defined in MPL_Standard/System/empl_runtime.h (not in CppBackend.h directly, but referenced by generated code). The contract:

Helper Purpose
__mpl_ws_create(url, protocols) Open a WebSocket connection
__mpl_ws_send(handle, payload, opcode) Send a frame
__mpl_ws_close(handle, code, reason) Close the connection
__mpl_ws_set_sni(hostname) Set the SNI hostname for the next TLS handshake
__mpl_ws_on(handle, event, listener) Register a listener for open / message / close / error

The SNI helper is what allows code like:

const ws = new WebSocket("wss://api.example.com/stream");

to negotiate TLS with SNI = api.example.com instead of the URL host (relevant when behind a CDN).

The C++ backend emits calls to these helpers verbatim — WebSocket is in jsGlobals (line 7896-7982) as a global lookup, and the new WebSocket(...) constructor is in jsFnGlobals2 (line 8513-8562). The lowering is identical to other JS class constructors (see ./closures.md for the new lowering).


host_handle_ref / host_handle_unref

These are runtime contract helpers used by the JS frontend when lowering Node-API (N-API) native addons or Worker / SharedArrayBuffer interop. They are declared in MPL_Standard/System/empl_runtime.h and called from generated code:

Helper Purpose
host_handle_ref(h) Increment the host-side reference count for handle h
host_handle_unref(h) Decrement; when zero, the host may free the underlying resource

Generated code emits these around new Worker(…) and around explicit native addon constructors:

const w = new Worker("./worker.js", { type: "module" });
auto __worker_handle = mpl_call(Worker, mpl_box_value("./worker.js"),
                                mpl_box_value(/* options obj */));
host_handle_ref(__worker_handle);

host_handle_unref is emitted at the C++ scope-exit ({ … } block end) when a variable falls out of scope.

The handlers exist because the JS frontend translates native handles into mpl_value-wrapped pointers; without host_handle_ref/unref, the host garbage collector would prematurely reclaim the underlying native resource.


Cross-references