crossbind
GitHub
GUIDE · CONCEPTS · C++ BINDINGS

C++ bindings

There are no binding macros to write: the generator reads your header and produces the bridge. In exchange it handles a constrained subset of C++. Stay inside it and everything binds for free; step outside and you wrap the offending API in something that does.

The rules

No raw pointers in the public API

Pointer arithmetic, ownership and aliasing have no JavaScript equivalent, so they are not exposed. Return values by value or through std::shared_ptr.

C++
"color:#78829a">// will not bind
MyClass* getInstance();
void process(int* data, size_t len);
char* getName();
 
"color:#78829a">// binds cleanly
std::shared_ptr<MyClass> getInstance();
void process(const std::vector<int>& data);
std::string getName();

The public surface belongs in the header

The binder reads headers. A class declared in the .h but defined only in the .cpp, an anonymous namespace around the API, or a file-scope static function is invisible to it. Public members bind; private ones stay internal, which is fine.

Single inheritance only

Single-base virtual polymorphism is supported. Multiple inheritance - diamonds especially - breaks the auto-binder; refactor to composition or wrap it.

Templates need explicit instantiation

C++
template<typename T> class Buffer { /* ... */ };
 
"color:#78829a">// name the instantiations you want in JavaScript
template class Buffer<int>;
template class Buffer<float>;
Returning unique_ptr
A std::unique_ptr returned across the boundary fails silently - the call comes back null or undefined. Use std::shared_ptr for anything JavaScript will own a handle to.

Primitive types

C++JavaScriptNote
voidundefined
booltrue / false
char, short, int and unsigned formsNumber
float, doubleNumber
long, unsigned long, int64_t, uint64_tBigIntboth directions - pass 9n, not 9
std::stringString
std::optional<T>the value or nullC++17, supported on every runtime
emscripten::valanythingthe untyped escape hatch

Vectors, maps and enums

Vectors of the primitive types are bound under generated names - VectorInt, VectorDouble, VectorString, VectorInt64 and friends. A vector of your own class follows the same rule: std::vector<MyClass> becomes VectorMyClass.

JavaScript
const myVector = getMyVector();
 
for (let i = 0; i < myVector.size(); i += 1) {
console.log(myVector.get(i));
}
 
const next = new VectorInt();
next.push_back(9);
setMyVector(next);

Two module helpers convert between the two worlds when you would rather work with plain arrays:

JavaScript
const values = m.toArray(getMyVector()); "color:#78829a">// vector -> Array
values.push(9);
setMyVector(m.toVector(VectorInt, values)); "color:#78829a">// Array -> vector

Maps bind the same way (MapIntInt, MapStringString, MapStringInt, MapIntString), enums arrive as objects with the enumerator names as keys, and both enum and enum class are supported.

Worker runtimes differ slightly
On direct runtimes a std::vector return is a real vector proxy; through a worker bridge it arrives as a plain JavaScript array. m.toArray() accepts both shapes, so code written against it works either way.

Exceptions

Throw from C++ and catch in JavaScript - that is the binding-friendly way to report failure, rather than status codes or out-parameters.

C++
double squareRoot(double x) {
if (x < 0) throw std::invalid_argument("sqrt of negative");
return std::sqrt(x);
}
JavaScript
try {
m.squareRoot(-1);
} catch (e) {
console.error(e.message); "color:#78829a">// "std::invalid_argument: sqrt of negative"
}

On wasm the message is "<type>: <what()>", with the halves also available as e.cppType and e.cppMessage. On React Native (JSI) it is the plain what() text.

Memory

There is no .delete() to call. Because no raw pointers cross the boundary, lifetime stays on the C++ side: destructors and shared_ptr reference counting do the work. Objects you hand to JavaScript are freed when the last reference goes away.

Wrapping what does not fit

Vendored library full of raw pointers, templates and multiple inheritance? Do not fight it - put a clean class in front of it. The wrapper is what binds; the upstream type stays internal.

src/native/wrapper.h
#pragma once
#include "upstream/upstream.h"
#include <memory>
#include <vector>
 
class CleanWrapper {
public:
CleanWrapper();
std::vector<float> process(const std::vector<float>& input);
private:
std::shared_ptr<upstream::RawType> raw_;
};

App-side wrappers live in your own src/native; if you are publishing a package, put the wrapper inside the package so every consumer benefits.

TypeScript

Declarations are generated for every header and Rust import, outside your source tree under .cppjs/. Add the shared config as a dev dependency and extend it once:

tsconfig.json
{ "extends": "@cpp.js/typescript-config" }

Running with initNative({ useWorker: true })? Set dts: 'promise' in cppjs.config.js so every generated signature returns Promise<...>, matching the async runtime - and write await new X(...) for construction.

The escape hatch

When neither the rules nor a wrapper fit, hand-write a SWIG interface file next to the header and import it from JavaScript. It is also how you register a container the generator does not cover:

src/native/mycustom.i
#pragma once
 
%module mycustom
 
%{
EMSCRIPTEN_BINDINGS(mycustom) {
emscripten::register_vector<Abc>("VectorAbc");
}
%}
 
%feature("shared_ptr");
%feature("polymorphic_shared_ptr");
src/index.js
import './native/mycustom.i';
Type to search every guide page and section.
↑↓ navigate↵ openesc close