You can define conversions for custom native types that fall outside the built-in mappings and library types.

See [Passing Data Across the Bridge](/docs/2.0/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](/docs/2.0/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:

```cpp
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:

```cpp
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:

```js
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.
