docs

Custom Type Conversions

Define custom value conversions for native types using the TypeTraits template.

On this page

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 the specialization and its conversion methods:

C++
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:

C++
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:

JavaScript
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.