Value marshalling at the boundary

What happens when a value crosses between untyped JavaScript and typed .fts code. The runtime checks it against the declared type, converts it into a fresh value the sound side owns, and refuses kinds like functions and promises that cannot be made sound. By default a value that fails the check halts at the crossing.

When a value crosses the sound edge, from JavaScript into typed .fts code, or back out, it does not simply change hands. The runtime marshals it: checks it against the declared contract, converts it into a representation the sound side can trust, and refuses the kinds that cannot be made sound at all. This page is the exhaustive account of that conversion, in both directions. It is the mechanics underneath the boundary system; for the map of every edge in the runtime, see the edges.

Marshalling happens at two tiers. The wrapper that performs it is allocated at module-link time (T1); the conversion and validation run at the call (T3). On the default secure policy, a value that fails to marshal HALTs, it never reaches the typed body as a wrong value.

Inbound: untyped JavaScript into sound code

This is the direction that matters, because it is where an unsound value tries to enter a world that has promised soundness. Every argument to a .fts export called from JavaScript, and every value returned by an imported JavaScript function, is marshalled against the type it is declared to have.

Every value is checked, including primitives

There is no unchecked scalar path. Calling a .fts export from JavaScript with a wrong-typed primitive HALTs at the crossing rather than computing a wrong result:

// core.fts exports: export function double(n: number): number
import { double } from "./core.fts";
double(21);    // 42
double("x");   // HALT: argument 0 for runtime export 'double'
               //       does not conform to parameter 'n'

Arity is part of the contract: too few or too many arguments HALTs (expects 1 argument(s), got 0).

Records and arrays are validated structurally

A record- or array-typed parameter is checked all the way down. A missing field, a wrong-typed field, or a non-object where a record is expected each HALT:

// export function describe(u: User): string   where User = { name: string, age: number }
describe({ name: "ada", age: 36 });  // ok
describe({ name: "ada" });           // HALT: argument 0 does not conform to parameter 'u'
describe({ name: "ada", age: "x" }); // HALT: wrong-typed field
describe("nope");                    // HALT: not an object

Validation is not shallow: a nested record or an array element is checked with the same rigor as the top level.

The refusal catalog

Some value kinds cannot be made sound no matter what type is declared, so the converter refuses them fail-closed, before they can reach a typed body:

Value kindTreatmentWhy
Callable (function)Refused (except a same-turn validated callback)A live function is realm-local authority; it crosses only by explicit grant, not inside a data payload
PromiseRefusedA pending value is not the value; a sound type describes what a thing is, not what it will become
Host object (live platform object)RefusedIts behavior is defined outside the sound world and cannot be re-checked
SymbolRefusedNot expressible as sound data crossing the edge
Sparse array (holes)RefusedA hole is an absence masquerading as an element
Prototyped array (non-plain)RefusedInherited or exotic behavior cannot be validated as a plain array

Everything else, plain records, dense arrays of sound elements, and the primitives, is converted into a fresh sound value the typed side owns.

null is not undefined

The converter does not conflate the two nullish values. A parameter typed undefined does not accept a JavaScript null; the two remain distinct at the crossing, closing a classic soundness trap where "empty" silently unifies.

When the policy is not secure

The refusal and validation rules above describe the secure (HALT) default. The other continue-modes change only what happens on a failed check, not what counts as a failure:

  • debug (propagate-as-unknown) lets the value cross typed as unknown and fires a violation callback; the consumer must narrow before use.
  • tolerant (sanitize) substitutes a declared inert default for the expected type and records provenance, never passing the unsound value through. Sanitize ships today at the ORM row boundary.

Outbound: sound code producing values

The reverse direction carries less risk, because the protected side is the one producing the value. The responsibility asymmetry means the sound side may choose to be less strict about what it returns, but may never blindly trust what unsound code returns to it.

That choice is skip return validation, and it is export-only: permitted on a function a compartment exports, forbidden on an import. A JavaScript caller that receives such a value gets it untouched, and the opt-out is recorded. There is no symmetric "skip argument validation": inbound values are always the untrusted direction.

The ORM row is the same machine

A database row is untyped data entering typed code, so the ORM marshals it with exactly this mechanism: each row is validated against its declared contract as it comes back, with the full set of continue-modes. A NULL in a non-null column, or an enum widened out from under the schema, is a marshalling failure like any other, HALT by default, sanitizable, or propagatable. A query issued without a contract is refused rather than letting an unvalidated row into a sound compartment. The database is simply the unsound side of a boundary, and its rows cross through the same gate as any other inbound value.

Characteristics

  • Fail-closed is the default everywhere. The failure mode of a crossing is a located error, never a silently coerced wrong value. double("x") HALTs; it does not quietly become NaN.
  • Identity is not preserved. A marshalled value is a fresh value the receiving side owns; nothing on the typed side ends up holding a reference into untyped heap state.
  • Repeated crossing is GC-safe. The wrapper machinery is designed for hot boundaries: thousands of repeated crossings under memory-stress hold steady rather than leaking wrapper or rooting state.
  • The cost is the check. Marshalling a primitive is close to free; the cost of a record or array crossing scales with the size of the structure being validated, because the guarantee is that the whole structure was checked.