Custom Type Conversions
Define custom value conversions for native types using the TypeTraits template.
You can define conversions for custom native types that fall outside the built-in mappings and library types.
See Passing Data Across the Bridge for the full list of built-in type conversions.
Choosing an Approach
You can bridge custom types using either value conversions or bound classes, depending on whether the type represents a value or an object with identity.
| Approach | Crosses As | Use It For |
|---|---|---|
Value conversion (js::TypeTraits<T>) |
A JavaScript value, copied each time it crosses | Small value types (IDs, units, handles as text) |
Bound class (api.DefineClass<T>()) |
A JavaScript object referencing your instance, without copying | Objects with identity and methods |
See Exposing Native Classes to bind a native class and manage instance ownership. The rest of this page covers value conversions with js::TypeTraits<T>.
The TypeTraits Template
Specialize js::TypeTraits<T> to define a custom value conversion for a type.
Declaring a Specialization
A specialization must satisfy these rules:
- Declare it in namespace
ultralight::js. - Make it visible before any code that exposes functions using the type or converts it.
- Ensure the type is default-constructible.
Declare the specialization and its conversion methods:
namespace ultralight::js {
template <>
struct TypeTraits<MyType> {
// Page to C++. `value` is borrowed (don't destroy it), and `*out` starts
// as a default-constructed MyType. Return false to fail the call with
// "expected MyType", or store an exception you created in `*exception`.
static bool FromJS(ULJSContext ctx, ULJSValue value, MyType* out,
ULJSValue* exception);
// C++ to page. Return a new value (the library takes it), or NULL for
// `undefined`.
static ULJSValue ToJS(ULJSContext ctx, const MyType& value);
// Required: the name in "expected MyType" errors and in the API schema.
static constexpr const char* SchemaType() { return "MyType"; }
// Optional: the JavaScript types that can convert (for std::variant).
static bool AcceptsJSType(js::Type type, bool is_array, bool is_function);
// Optional: the schema type, when MyType crosses as a plain JavaScript type.
static void WriteSchemaExpr(std::string& out);
};
} // namespace ultralight::js
Specialization Members
A TypeTraits specialization defines these static members:
| Member | Required | Purpose |
|---|---|---|
SchemaType() |
Yes | Returns the type name for "expected X" error messages and the API schema. |
FromJS() |
Page to C++ only | Converts a borrowed value into *out. Returning false fails the call with an "expected X" error. |
ToJS() |
C++ to page only | Returns a new value that the library takes over. Returning NULL produces undefined. |
AcceptsJSType() |
Optional | Identifies which JavaScript types can convert so std::variant can pick the type. |
WriteSchemaExpr() |
Optional | Appends the schema expression when the type crosses as a plain JavaScript type. |
If a type crosses the bridge in one direction only, leave out FromJS() or ToJS().
A Complete Example
JavaScript numbers are 64-bit floats and lose integer precision past 2^53. A 64-bit identifier can cross the bridge as a decimal string instead.
Once specialized, your type works everywhere a built-in type works, including parameters, return values, and containers.
Specialize TypeTraits<EntityId> to convert 64-bit integers as strings:
struct EntityId {
uint64_t value = 0;
};
namespace ultralight::js {
template <>
struct TypeTraits<EntityId> {
static bool FromJS(ULJSContext ctx, ULJSValue value, EntityId* out,
ULJSValue* exception) {
std::string text;
if (!TypeTraits<std::string>::FromJS(ctx, value, &text, exception))
return false;
const char* end = text.data() + text.size();
auto parsed = std::from_chars(text.data(), end, out->value);
return parsed.ec == std::errc() && parsed.ptr == end;
}
static ULJSValue ToJS(ULJSContext ctx, const EntityId& id) {
return TypeTraits<std::string>::ToJS(ctx, std::to_string(id.value));
}
static constexpr const char* SchemaType() { return "EntityId"; }
static bool AcceptsJSType(js::Type type, bool is_array, bool is_function) {
return type == js::Type::String;
}
static void WriteSchemaExpr(std::string& out) { out += "\"string\""; }
};
} // namespace ultralight::js
void RegisterEntityAPI(js::API& api) {
api["select"] = [](EntityId id) { Log(std::to_string(id.value)); };
api["party"] = [] {
return std::vector<EntityId> { { 9007199254740993 }, { 42 } };
};
}
Page script passes and receives the ID as a string, preserving exact integer precision:
myApp.select("9007199254740993"); // exact, past 2^53
const ids = myApp.party(); // ["9007199254740993", "42"]
myApp.select(42);
// TypeError: myApp.select(EntityId): argument 1: expected EntityId,
// got number
Constants bound directly to the API (such as api["x"] = value) are copied as JSON when registered and ignore js::TypeTraits specializations.