---
title: "Expose .NET objects with [JsExport]"
url: https://recursiveai.org/oneecma/cookbook/jsexport/
description: "Mark C# classes, records and enums with [JsExport] and OneEcma’s source generator writes the bindings at build time: methods, properties, async methods…"
version: "1.0.0"
---

# Expose .NET objects with [JsExport]

Mark C# classes, records and enums with `[JsExport]` and OneEcma’s source generator writes the bindings at build time: methods, properties, async methods and callbacks work in script, with no reflection at run time, and a wrong argument names the .NET member, the path and both types.

```shell
dotnet add package RecursiveAI.OneEcma --version 1.0.0
dotnet add package RecursiveAI.OneEcma.Generators --version 1.0.0
```

`RecursiveAI.OneEcma.Generators` is an analyzer: it runs in the compiler and adds nothing to your output or dependencies. `dotnet add package` references it with `PrivateAssets="all"` already.

`Shop.cs`:

```csharp
using RecursiveAI.OneEcma;

namespace Shop;

[JsExport]
public enum OrderStatus { Pending, Paid, Shipped }

[JsExport]
public record LineItem(string Sku, int Quantity, decimal Price);      // copied by value

[JsExport(RejectUnknownProperties = true)]
public record Order(int Id, List<LineItem> LineItems, OrderStatus Status = OrderStatus.Pending);

[JsExport]
public class OrderApi                                                  // shared by reference
{
    private readonly Dictionary<int, Order> _orders = new()
    {
        [1] = new Order(1, [new LineItem("pen", 3, 1.5m), new LineItem("ink", 1, 7.25m)]),
    };

    public decimal TaxRate { get; set; }

    public Order GetOrder(int id) =>
        _orders.TryGetValue(id, out Order? o) ? o : throw new KeyNotFoundException($"No order with id {id}");

    public Order AddOrder(Order order) => _orders[order.Id] = order;

    public decimal Total(Order order) => order.LineItems.Sum(i => i.Quantity * i.Price) * (1 + TaxRate);

    public async Task<Order> FetchOrderAsync(int id)
    {
        await Task.Delay(5);
        return GetOrder(id);
    }

    public void ForEach(Action<Order> visit)
    {
        foreach (Order o in _orders.Values) visit(o);
    }

    [JsIgnore]
    public void DeleteAll() => _orders.Clear();                        // not visible to script
}
```

`Program.cs`, in a project whose `RootNamespace` is `Shop`:

```csharp
using RecursiveAI.OneEcma;
using RecursiveAI.OneEcma.Diagnostics;
using Shop;

var api = new OrderApi();
var engine = new ScriptEngine(new ScriptEngineOptions
{
    Configure = e =>
    {
        JsExports.InstallAll(e.MainRealm);                             // generated: every [JsExport] type
        e.MainRealm.DefineData(e.Global, "api", e.MainRealm.Wrap(api));
    },
});

JsValue result = await engine.AwaitAsync(engine.Evaluate("""
    (async () => {
      api.taxRate = 0.1;
      api.addOrder({ id: 2, lineItems: [{ sku: "book", quantity: 2, price: 12 }], status: "Paid" });
      const order = await api.fetchOrderAsync(1);
      const lines = [];
      api.forEach(o => lines.push(`#${o.id} ${o.status}: ${api.total(o)}`));
      return [order.lineItems.length + " lines on #1", ...lines, typeof api.deleteAll, OrderStatus.Shipped].join("\n");
    })()
    """, "orders.js"));
Console.WriteLine(engine.ToDisplayString(result));
Console.WriteLine($"C# sees tax rate {api.TaxRate}");

string[] mistakes =
[
    "api.addOrder({ id: 3, lineItems: [{ sku: 'pen', quantity: 1, price: '7.25' }] });",
    "api.addOrder({ id: 4, lineItems: [], staus: 'Paid' });",
    "api.getOrder(99);",
];
foreach (string source in mistakes)
{
    try
    {
        engine.Evaluate(source, "case.js");
    }
    catch (JsException e)
    {
        Console.WriteLine(DiagnosticRenderer.Render(e.Report!));
    }
}
```

```text
2 lines on #1
#1 Pending: 12.925
#2 Paid: 26.4
undefined
Shipped
C# sees tax rate 0.1
error[TypeError]: Shop.OrderApi.AddOrder: argument 'order.lineItems[0].price' must be a finite number (System.Decimal), got string "7.25"
 --> case.js:1:1
  |
1 | api.addOrder({ id: 3, lineItems: [{ sku: 'pen', quantity: 1, price: '7.25' }] });
  | ^^^^^^^^^^^^ `order.lineItems[0].price` is string "7.25", expected a finite number (System.Decimal)
  |
  = help: convert the string first: Number("7.25")
  = note: host function: Shop.OrderApi.AddOrder(Order order)
  = stack: Shop.OrderApi.AddOrder [.NET] ← <top-level> (case.js:1)

error[TypeError]: Shop.OrderApi.AddOrder: argument 'order' has no property 'staus'
 --> case.js:1:1
  |
1 | api.addOrder({ id: 4, lineItems: [], staus: 'Paid' });
  | ^^^^^^^^^^^^ unknown property 'staus' in `order`
  |
  = help: did you mean 'status'?
  = help: it accepts: id, lineItems, status
  = note: host function: Shop.OrderApi.AddOrder(Order order)
  = stack: Shop.OrderApi.AddOrder [.NET] ← <top-level> (case.js:1)

error[Error]: KeyNotFoundException: No order with id 99
 --> case.js:1:1
  |
1 | api.getOrder(99);
  | ^^^^^^^^^^^^
  |
  = note: thrown by .NET code: System.Collections.Generic.KeyNotFoundException in Shop.OrderApi.GetOrder
  = stack: Shop.OrderApi.GetOrder (Shop.cs:25) ← <top-level> (case.js:1)
```

## What crosses, and how

- **Classes are shared by reference.** Script holds a handle to the .NET object, so `api.taxRate = 0.1` in script is `api.TaxRate` in C#. `realm.Wrap(obj)` returns the same script object each time.
- **Records and structs are copied by value.** A plain script object becomes a new `Order`; an `Order` returned to script becomes a plain object. `RejectUnknownProperties = true` turns a misspelled property into an error with a suggestion, as with `staus` above.
- **Names become camelCase** (`GetOrder` → `getOrder`); types keep their names. Enums cross as member names, case-insensitive on the way in.
- **Async and callbacks.** A `Task<T>` return is a promise, and an `Action<T>` or `Func<…>` parameter accepts a script function. `IEnumerable<T>` becomes an iterator.
- **Conversions are strict.** The string `"7.25"` isn’t accepted where a `decimal` is expected; the report says so and suggests `Number("7.25")`.
- **Only exported members exist.** `[JsIgnore]` hides a member, and `Equals`, `GetHashCode` and `GetType` are never exported. Nothing else of .NET is reachable from script.

## What the generator writes

Build with `<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>` to see the files under `obj/…/generated/RecursiveAI.OneEcma.Generators/`. For the project above it writes one binding per type and `JsExports.g.cs`:

```csharp
// <auto-generated/>
namespace Shop
{
    /// <summary>Installs every <c>[JsExport]</c> type of this assembly into a realm.</summary>
    public static class JsExports
    {
        public static void InstallAll(JsRealm realm)
        {
            realm.Install(global::Shop.OrderApiJsBinding.JsType);
            realm.Install(global::Shop.OrderStatusJsBinding.JsType);
        }

        /// <summary>The script names of the exported types, in install order.</summary>
        public static readonly string[] Names = ["OrderApi", "OrderStatus"];
    }
}
```

Each binding is plain C# over the public host API. This is the start of `getOrder` in `Shop.OrderApiJsBinding.g.cs`:

```csharp
private static JsValue M0(JsRealm realm, JsValue thisValue, global::System.ReadOnlySpan<JsValue> args)
{
    global::Shop.OrderApi self = JsMarshal.This<global::Shop.OrderApi>(thisValue, "Shop.OrderApi.GetOrder");
    var c = new JsConv(realm, "Shop.OrderApi.GetOrder", "Shop.OrderApi.GetOrder(int id)");

    c.Arg("id");
    if (args.Length <= 0)
    {
        c.Missing("an integer (System.Int32)");
        throw c.ToException();
    }
    // …
```

Because the marshalling is written at build time, it works under NativeAOT and trimming.

## Mistakes caught at build time

A type the generator can’t bind is a compile error with the fix in the message. Two overloads that accept the same kinds of script values, for example:

```csharp
[JsExport]
public class Pricing
{
    public decimal Quote(int quantity) => quantity * 1.5m;
    public decimal Quote(long quantity) => quantity * 1.5m;
}
```

```text
Bad.cs(9,20): error OE1004: Overloads 'Shop.Pricing.Quote(int quantity)' and 'Shop.Pricing.Quote(long quantity)' of 'Shop.Pricing.Quote' accept the same kinds of script values for 1 argument(s), so a call cannot pick one. Fix: give one a different script name with [JsExport(Name = "...")], make their parameter kinds differ, or add [JsIgnore] to one.
```

A type with errors gets no binding and is left out of `InstallAll`; the other types still generate.

## Good to know

- Put `InstallAll` and `Wrap` in `Configure`, as above. A failed run is replayed to explain it; without the setup there the replay can’t reproduce the run, and the report gains `= note: explanation unavailable: …` in place of what the replay would add.
- `JsExports` is generated in the project’s root namespace.
- Every supported type, the full list of boundary messages and the eight generator diagnostics are in [[JsExport] bindings](https://recursiveai.org/oneecma/docs/js-export/). A larger worked host is [samples/OrderHost](https://github.com/recursive-ai-oss/oneecma-csharp/tree/v1.0.0/samples/OrderHost).
