---
title: "[JsExport]: .NET types as script classes"
url: https://recursiveai.org/oneecma/docs/js-export/
description: "Mark a type with [JsExport] and a Roslyn generator (the RecursiveAI.OneEcma.Generators package, analyzer-only) writes its binding at build time. There is…"
version: "1.0.0"
source: https://github.com/recursive-ai-oss/oneecma-csharp/blob/aa60ea782eaf7093ce542d7bdc5570aef269d120/docs/js-export.md
---

# `[JsExport]`: .NET types as script classes

Mark a type with `[JsExport]` and a Roslyn generator (the `RecursiveAI.OneEcma.Generators` package, analyzer-only) writes its binding at build
time. There is no reflection at run time, so it works under NativeAOT and trimming. Only exported members exist in script.

```csharp
[JsExport] public enum OrderStatus { Pending, Paid, Shipped }
[JsExport] public record LineItem(string Sku, int Quantity, decimal Price);
[JsExport] public record Order(int Id, List<LineItem> LineItems, OrderStatus Status = OrderStatus.Pending, string? Note = null);

[JsExport]
public class OrderApi
{
    public Order GetOrder(int id) { … }
    public Task<Order> FetchOrderAsync(int id) { … }
    public void ForEach(Action<Order> visit) { … }
}

// host
JsExports.InstallAll(engine.MainRealm);                                 // generated, in the project's root namespace
engine.MainRealm.DefineData(engine.Global, "api", engine.MainRealm.Wrap(new OrderApi()));
```

```js
const order = await api.fetchOrderAsync(2);       // a Promise
api.forEach(o => print(o.id));                    // a callback
api.addOrder({ id: 3, lineItems: [{ sku: "pen", quantity: 1, price: 1.5 }] });
```

A runnable version is `samples/OrderHost`. Reference the generator as an analyzer (`PackageReference` with `PrivateAssets="all"` for the package);
it reads the project's `RootNamespace` through `CompilerVisibleProperty`, which the package's `build/*.props` sets.

## What gets exported

| Declaration | In script |
|---|---|
| `class` (default) | A class: `new C(...)` for each public constructor, instance members on its prototype, statics on the constructor. Instances are **shared by reference**: script holds a handle to the .NET object. Derived exported classes inherit through the prototype chain. |
| `interface` | A prototype for values returned through it; no constructor. `Wrap(obj)` finds the prototype of the object's class first. |
| `static class` | A plain global object holding the static members. |
| `record`, `struct` | **Copied by value**: a script object becomes a new .NET value; a .NET value becomes a new plain object. Only data members (public readable properties and fields) take part. Override with `Kind = JsExportKind.Reference`/`Value`. |
| `enum` | A global object of member names; values cross as the member **name** (case-insensitive on the way in) or its number. |

Members of an exported class: public instance and static methods, properties (get/set accessors on the prototype; a read-only property has no
setter, so assignment is ignored in sloppy code and a TypeError in strict code), fields (as accessors), `const` fields (read-only values). Members of
`object` (`Equals`, `GetHashCode`, `GetType`, an unannotated `ToString`) are never exported. `[JsIgnore]` leaves a member out; `[JsExport(Name = "x")]` renames it.

**Names.** Members become camelCase (`GetOrder` → `getOrder`, `URL` → `url`, `ID` → `id`; a leading run of capitals lowers all but the last when a lowercase
follows: `HTMLPage` → `htmlPage`). Types keep their name. Enum member names are kept as written.

**Overloads.** Overloads share one script name. A call picks the most specific overload whose parameter kinds accept the arguments: integer, number,
string, boolean, bigint, array, plain object, function, host object of a given class, enum (string or number), `JsValue` (anything).
`int` is chosen over `double` for integral numbers. Overloads that accept the same kinds for some argument count are a build error (OE1004); a call that matches none
is a TypeError listing the overloads.

**Parameters.** Required, optional (`= default`, or a trailing nullable parameter), and `params T[]`. A missing or `undefined` optional argument takes the default.

**Supported types.** `bool`, all integer types (`long`/`ulong` cross as a number when exact, else a BigInt; a BigInt is accepted when it fits), `float`,
`double`, `decimal` (finite numbers), `System.Numerics.BigInteger` (BigInt), `string`, `char` (one-character string), enums, `T?` and nullable reference
types (`null` and `undefined` give null), `T[]`, `List<T>` and the usual interfaces (`IList`, `IReadOnlyList`, `IEnumerable`, `ICollection`) as arrays,
`Dictionary<string, T>` and its interfaces as plain objects, `JsValue` and `JsObject` (passed through), `Func<…>`/`Action<…>` up to six parameters
(script functions; a callback that returns a `Task`/`Task<T>` awaits the promise the script function returns), and other exported types.

**Returns.** `void` (undefined), values as above, `Task`, `Task<T>`, `ValueTask`, `ValueTask<T>` (a Promise), `IEnumerable<T>` (an iterator: items are
produced as script asks, `return()` disposes), `IAsyncEnumerable<T>` (an async iterator whose `next()` returns promises). An exception thrown synchronously by a task-returning method
rejects the promise instead of throwing.

**Conversions are strict.** A number is not accepted where a string is expected, and a string is not accepted where a number is: the failure says so and
suggests the conversion (`Number("7.25")`). By-value types read their properties by exported name; a property missing from the script object is an error
when the .NET member is required (a constructor parameter without default, a `required` member, a non-nullable property) and leaves the default otherwise.
`RejectUnknownProperties = true` makes an extra property an error that suggests the closest name.

## Boundary errors

Every error has the form `<Member>: <what> …` as a `TypeError` whose `Diagnostic` has the call as the primary span, a label stating the path and the finding,
`help:` lines, `= note: host function: <signature>` and a stack whose first frame is the .NET member:

```
error[TypeError]: Shop.OrderApi.AddOrder: argument 'order.lineItems[1].price' must be a finite number (System.Decimal), got string "7.25"
 --> case.js:6:1
  |
6 | api.addOrder({ id: 3, lineItems: lines });
  | ^^^^^^^^^^^^ `order.lineItems[1].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:6)
```

| Situation | Headline after `<Member>: ` |
|---|---|
| wrong type | `argument '<path>' must be <expected>, got <actual>` |
| missing argument or property | `argument '<path>' is missing; expected <expected>` |
| unknown property (when rejected) | `argument '<path>' has no property '<name>'` + `help: did you mean '<name>'?` + the accepted names |
| enum name | `argument '<path>' must be one of 'A', 'B' (Enum), got string "…"` + did-you-mean |
| callback result | `the value returned by callback '<name>' must be <expected>, got <actual>` |
| no overload | `no overload accepts (<kinds>)` + one note per overload |
| wrong `this` | `called on an incompatible receiver (this is <actual>); expected a <Class> object` |

The path is the parameter name followed by `.property` and `[index]` steps. `<actual>` is `undefined`, `null`, `number 1.5`, `string "…"` (cut at 40 characters),
`boolean true`, `bigint 5n`, `a function`, `an array of 3 items`, `an object with keys a, b`, `an empty object` or `a symbol`. These messages are ours (V8 has no counterpart); the
name of the error and its place in `err.stack` follow V8.

Nesting of by-value types is limited to 64 levels in both directions, so a script object that contains itself or a cyclic .NET graph ends in an error (`TypeError` naming the path, or `RangeError` copying to script) instead of overflowing the stack.

A .NET exception thrown by an exported member arrives as `Error: <TypeName>: <message>` with `dotnetType`, and as `JsException.InnerException` in C#. Its report lists the .NET
frames (with file and line when debug information exists, method names only under NativeAOT) before the script frames.

## Build-time diagnostics

All are errors with a fix in the message.

| Id | Raised when | Fix |
|---|---|---|
| OE1001 | the declaration cannot be exported: generic type, `ref struct`, delegate, type nested in a non-accessible type | export a closed or non-generic type |
| OE1002 | a member shape has no script form: `ref`/`out`/`in` parameter, indexer, event, generic method, ref return | `[JsIgnore]`, or change the shape (events: take an `Action<…>`) |
| OE1003 | a member uses a type that cannot cross: `object`, an unexported class, `Dictionary<int, …>`, multi-dimensional array, pointer, a task as a parameter | add `[JsExport]` to the type, use `JsValue`, use `Dictionary<string, T>` |
| OE1004 | two overloads accept the same kinds of script values for some argument count | rename one with `[JsExport(Name = "…")]`, make kinds differ, or `[JsIgnore]` |
| OE1005 | two members (or two exported types) get the same script name | rename or ignore one |
| OE1006 | a by-value type cannot be built from a script object | add a parameterless constructor, or one whose parameters are named like its properties |
| OE1007 | a `Name` is not an identifier | letters, digits, `_`, `$`, not starting with a digit |
| OE1008 | `Kind` does not fit the type (a by-reference struct; a by-value interface, static or abstract class) | change `Kind` or the type |

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

## Generated code

For each exported type `T` the generator writes `TJsBinding` (same namespace; `internal` when the type is) with `JsType` (a `JsHostType` for `realm.Install`), and
`JsExports.g.cs` with `InstallAll(JsRealm)` and `Names` in the project's root namespace. By-value types also get `TryFromJs`/`ToJs`. The code is plain C# over the public
types in `host-api.md`; read it by building with `EmitCompilerGeneratedFiles=true`. Output order is deterministic. `tests/OneEcma.Generators.Tests/Snapshots` holds the
expected text for five shapes.

## JSON-Schema tools

`JsonSchemaTools` registers a function whose single argument is checked against a JSON Schema before the handler runs, with the same error form
(`createOrder: argument 'args.items[0].price' must be a number, got string "2"`, path root `args`):

```csharp
JsObject fn = JsonSchemaTools.Define(realm, "createOrder", schemaJson, arg => /* JsonElement in, JsonElement out */);
JsObject typed = JsonSchemaTools.Define(realm, "search", schemaJson, ToolJson.Default.SearchArgs, ToolJson.Default.SearchHitArray, args => Search(args));
JsObject slow = JsonSchemaTools.DefineAsync(realm, "slow", schemaJson, async arg => …);      // returns a Promise
JsObject checkedCall = JsonSchemaTools.Validated(realm, "name", schemaJson, existingFunction, thisValue);   // wrap an exported method
realm.DefineData(realm.Global, "createOrder", JsValue.FromObject(fn), JsAttributes.Method);
```

In 1.0.0 the `JsonElement` a `DefineAsync` handler receives is valid only until the handler first awaits: read what you need from it, or keep `arg.Clone()`, before the first `await`.

Typed overloads take `JsonTypeInfo<T>` from a `System.Text.Json` source-generated context, so they are AOT-safe; the context's naming policy decides the
.NET property names (use camel case to match schemas). The script value is converted to JSON by own enumerable properties (functions, symbols and `undefined`
properties are left out; non-finite numbers become `null`; nesting deeper than 64 is an error).

Supported keywords: `type` (also a list), `enum`, `const`, `properties`, `required`, `additionalProperties` (false or a schema), `items`, `minItems`, `maxItems`,
`minLength`, `maxLength` (UTF-16 units), `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`, `pattern` (.NET regular expression with a 200 ms
limit; a timeout counts as no match), `allOf`, `anyOf`, `oneOf`, `not`, local `$ref`. Everything else is ignored. A schema that is not JSON is an `ArgumentException`.
