Skip to content

Marinada ​

Status: implementation in progress. The JS implementation is complete: JIT compiler (to native JS), tree-walking evaluator, gradual type checker, and module system are all implemented with tests. The spec is settled.

Marinada is the expression language at the core of Dusklight. It is a small, gradually-typed language designed for data manipulation and world-boundary actions.

Expressions are JSON arrays — s-expressions as a data structure, not a custom syntax. No custom parser required; implementations evaluate JSON directly.

The JSON array is the canonical runtime format. Programs can be loaded from anywhere — config files, API responses, user input — and evaluated dynamically. Typed constructors in TS and Rust are a dev-time authoring layer that compiles down to the same JSON arrays. The runtime doesn't know or care which path produced an expression.

Two implementations derive from this spec:

  • JS — JITs to native JS signals (Solid.js-style). Used for JSON/text data. Owns the reactive graph in browser contexts.
  • Rust/WASM — Bytecode + JIT. Used for binary formats and heavy computation. Computes values; JS signals layer owns reactivity in the browser. For native targets, a Rust signals system (e.g. leptos) owns reactivity instead.

Both implementations must produce identical results for all valid programs.

Reactivity ​

Reactivity is a compiler concern, not a runtime polling concern. The compiler analyzes each expression's dependencies and emits reactive wiring — subscriptions are never written manually.

In the JS implementation, Marinada expressions compile to signals. A property expression like ["get", settings, "spacing"] compiles to a signal that updates exactly when settings.spacing changes. Only affected expressions re-evaluate — no VDOM diffing, no full re-renders.

This makes layout property bindings (à la QML) first-class: any layout property can be a Marinada expression, and the compiler ensures it stays in sync with its dependencies automatically.


Format ​

A Marinada expression is a JSON value:

  • Atom: any JSON primitive — 42, 3.14, "hello", true, false, null
  • Call: a JSON array [op, arg1, arg2, ...] where op is a string naming the operation and args are expressions
json
["set", ["var", "state"], "counters", ["+", ["get", ["var", "state"], "counters"], 1]]

Symbols (op names) are plain JSON strings in the first position of an array. They are not first-class values.

Strings and variables ​

A bare JSON string in argument position is always a string literal — unconditionally and independent of its value. It evaluates to itself and the type checker types it as string. This is what makes the example above read directly: "counters" is a literal field name, not a lookup.

A variable reference is the explicit op ["var", name] (arity 1). Its single argument is a bare string read raw (never evaluated). Evaluating ["var", name] looks name up in the environment; an unbound name is an UNDEFINED_VAR error.

json
["+", ["var", "x"], 1]                    // x + 1  — x is a variable
["+", "x", 1]                              // type error: "x" is a string literal, not a number
["get", ["var", "record"], "x"]           // field "x" of the record bound to `record`
["get", ["var", "record"], ["var", "k"]]  // dynamic field: key held in variable k

Prior art: this mirrors JSONLogic's {"var": x} and CloudFormation's {"Ref": ...} — a plain-JSON, value-independent reference marker with no custom lexer.

__lit is retired for authors. It survives only as (a) the optimizer's internal constant-fold node and (b) a deprecated compatibility alias: ["__lit", s] still evaluates, typechecks, and compiles as the literal s. New code writes bare strings for string literals.

There are no comments. Use JSON structure for clarity.


Type System ​

Marinada is gradually typed. Every expression has a type. Types are checked before evaluation.

Primitive Types ​

TypeDescription
int64-bit signed integer. JS impl uses BigInt.
float64-bit IEEE 754 float.
stringUTF-8 string
booleantrue or false
nullnull — JSON-compatible nil value
unknowntype not yet known

Compound Types ​

TypeDescription
array<T>homogeneous sequence
record<K, V>map from K to V
T | Uunion
bytesraw byte sequence

Discriminated Unions ​

Marinada has native DU support. DUs are required for renderer dispatch and for modeling protocol message types, state machines, and domain variants.

json
["type", "Color", ["Red"], ["Green"], ["Blue"]]
["type", "Shape",
  ["Circle", ["radius", "float"]],
  ["Rect",   ["width", "float"], ["height", "float"]]]

Constructors:

json
["Red"]
["Circle", 1.5]

Pattern matching via match — exact, exhaustive, deterministic:

json
["match", ["var", "shape"],
  [["Circle", "r"], ["*", 3.14159, ["*", ["var", "r"], ["var", "r"]]]],
  [["Rect", "w", "h"], ["*", ["var", "w"], ["var", "h"]]]]

The type checker enforces exhaustiveness. Non-exhaustive match is a compile error.

Standard Library DUs ​

option<T> and result<T, E> are library DUs, not special cases:

option<T> = None | Some(T)
result<T, E> = Ok(T) | Err(E)

null is a JSON-compatible nil, distinct from None. Use option<T> for typed optionality, null when interfacing with JSON data that uses it.

Error Handling ​

Errors are data. result<T, E> is the propagation mechanism — no exceptions.

The ? op propagates errors up: if the inner expression is Err, return it immediately; if Ok, unwrap:

json
["?", ["some-op-returning-result", ...]]

letrec for mutually recursive definitions (required for tree traversal):

json
["letrec", [["f", ["fn", ["x"], ["call", ["var", "f"], ["var", "x"]]]]], ["call", ["var", "f"], 0]]

TCO is guaranteed — implementations must not overflow the stack on tail-recursive calls. The compiler transforms tail calls; user code does not need trampolining.

unknown ​

The default type. All external data (source output, plugin op return values without declared types) is unknown until narrowed.

unknown cannot be used directly in typed operations — it must be narrowed first via ["as", T, expr] or ["is", T, expr].

["untyped", expr] ​

Explicit escape hatch. Bypasses type checking for expr and all sub-expressions. Use when working with data whose shape genuinely cannot be statically described.

json
["untyped", ["get", ["var", "data"], ["var", "some-dynamic-key"]]]

Type of ["untyped", expr] is unknown.


Built-in Operations ​

Variables ​

json
["var", name]   // reference the variable `name`. name is a bare string, read raw.

name is not evaluated. Unbound names raise UNDEFINED_VAR. A bare string anywhere else is a string literal (see Strings and variables).

Data Access ​

json
["get", record, key]           // get field. key is a string/number literal, or ["var", k] for a dynamic key.
["get-in", record, path]       // get nested field. path is array<string|number>.
["set", record, key, value]    // return new record with key set to value.
["set-in", record, path, val]  // return new record with nested path set.

Here record and value are expressions (often ["var", ...]); a bare-string key is a literal field name.

Arithmetic ​

json
["+", a, b]   ["-", a, b]   ["*", a, b]   ["/", a, b]   ["%", a, b]

All require number. Return number.

Comparison ​

json
["==", a, b]   ["!=", a, b]   ["<", a, b]   [">", a, b]   ["<=", a, b]   [">=", a, b]

== and != accept any type. Others require number. All return boolean.

Logic ​

json
["and", a, b]   ["or", a, b]   ["not", a]

Require boolean. Return boolean.

Control Flow ​

json
["if", cond, then, else]
["cond", [test1, expr1], [test2, expr2], ["else", exprN]]
["do", expr1, expr2, "..."]
["let", [[name, val], "..."], expr]

if: cond must be boolean. then/else must have the same type. cond: first matching branch is evaluated. do: evaluate in sequence, return last. let: bind names in scope of expr. Binding names are bare strings; refer to them in the body with ["var", name].

Type Operations ​

json
["as", "T", expr]      // assert expr is T at runtime. error if not.
["is", "T", expr]      // returns boolean. does not narrow in type checker.
["untyped", expr]      // escape hatch. see above.

Collections ​

json
["array", e1, e2, "..."]    // construct an array.
["count", coll]             // return length of array or record.
["array-get", arr, i]       // get element at index i.
["array-push", arr, elem]   // return new array with elem appended.
["array-slice", arr, s, e]  // return subarray [s, e).
["record-get", r, key]      // get field.
["record-set", r, key, val] // return new record with key set.
["record-del", r, key]      // return new record without key.
["record-keys", r]          // return array<string> of keys.
["record-vals", r]          // return array of values.
["record-merge", r1, r2]    // return new record with r2 keys overriding r1.

map, filter, reduce, and other higher-order collection functions are defined in lib:std as ordinary Marinada letrec expressions over these primitives. The compiler sees through them and optimizes (constant folding, loop fusion) by pattern-matching on the resulting AST.

String Operations ​

json
["str-len", s]              // length in Unicode code points.
["str-get", s, i]           // code point at index i as int.
["str-concat", s1, s2]      // concatenate two strings.
["str-slice", s, start, end]// substring [start, end).
["str-cmp", s1, s2]         // -1 | 0 | 1.
["to-string", val]          // convert any primitive to string.
["parse-int", s]            // string → int | null.
["parse-float", s]          // string → float | null.

Higher-level string ops (split, trim, starts-with, ends-with, replace, to-upper, to-lower) are defined in lib:std.

Math ​

json
["floor", x]   ["ceil", x]    ["round", x]
["abs", x]     ["min", a, b]  ["max", a, b]
["pow", a, b]  ["sqrt", x]
["int->float", x]   ["float->int", x]   // explicit numeric coercions

Bitwise ​

json
["bit-and", a, b]  ["bit-or", a, b]  ["bit-xor", a, b]
["bit-not", a]     ["bit-shl", a, n]  ["bit-shr", a, n]

Function & Method Calls ​

json
["call", f, arg1, arg2]           // call a function or lambda. f is an expression, usually ["var", name].
["call.method", cap, method, ...] // call a named method on a capability object.

call.method is a family of ops where the method name is a bare string literal (structural, read raw — not a variable). The type checker resolves the method signature from the capability's type.

json
["call.method", networkCap, "get", "https://api.example.com/data"]
["call.method", storageCap, "set", "key", value]

Capabilities ​

Capabilities are typed opaque objects — unforgeable values that grant authority. They are received as arguments (never constructed from within a program) and exercised via call.method.

Capability Type ​

Cap<T>

Where T describes the capability's interface. A Cap<Network> grants network access; a Cap<Storage> grants storage access. The type checker knows which methods are available on each capability type.

Capabilities can be attenuated — a sub-expression can be handed a narrower capability (e.g. a Cap<Network> restricted to one host).

There is no ambient authority. A program that hasn't been handed a capability cannot exercise it.

Built-in Capability Types ​

TypeMethods
Cap<Network>get, post, put, delete, ws
Cap<Storage>get, set, delete, list
Cap<LocalAgent>plugin-defined

Plugin-defined capability types are declared in the plugin manifest alongside their method signatures, so the type checker can validate their use.


Error Format ​

All errors include:

  • Path: JSON path to the offending sub-expression (e.g. [2, 1] = second arg of third arg)
  • Code: machine-readable error code (e.g. TYPE_MISMATCH, UNKNOWN_OP)
  • Message: human-readable description
  • Expected / Got: for type errors, what was expected and what was found
  • Suggestion: optional, when a fix is obvious

Example:

json
{
  "code": "TYPE_MISMATCH",
  "path": [1],
  "message": "expected number, got string",
  "expected": "number",
  "got": "string",
  "suggestion": "use [\"parse-number\", ...] to convert"
}

Compiler Architecture ​

The compiler knows a fixed, complete primitive set — the ops listed above. This set is the entire language core; it is not extensible. Plugins that need native behavior expose it through capabilities (call.method), not by registering new ops.

lib:std is just a module. It is loaded and resolved exactly like any other module — no special-casing in the compiler or runtime. map, filter, reduce, and other standard functions are defined as ordinary Marinada letrec expressions over the primitives above. The compiler sees through them transparently.

Optimization pipeline ​

The optimizer runs before backend execution, transforming the Marinada AST into a normalized form. This is not a runtime concern — it happens when an expression is first compiled/loaded, once.

The optimizer is a tree automaton rule engine: rules are indexed by their root op, applied bottom-up in a fixed-point pass. Rules fire on structural patterns in the AST — there are no privileged names. A user-written recursive function that structurally matches a known pattern is optimized identically to the lib:std version.

Pipeline:

  1. Constant folding — ["+", 1, 2] → ["__lit", 3n]. Applies to any pure expression over known values, including compound values (arrays, records). ["__lit", v] is the optimizer's internal literal node (also a deprecated author-facing alias); see Strings and variables.
  2. TCO — tail-recursive letrec → ["__loop", ...] nodes. This is the key normalization step: after TCO, all loops have a single canonical form.
  3. Loop pattern recognition — __loop nodes matching known patterns (array-map, array-filter, array-reduce, etc.) → ["__native", ...] nodes. Fires on ANY structurally matching loop — lib:std, user-defined, or generated. No name checking.
  4. Loop fusion — adjacent __native array operations fused to a single pass (gated on empty effect rows).
  5. Inlining — small, non-looping functions inlined at call sites.

Backends ​

The optimizer is backend-agnostic. After it runs, every backend — JIT, interpreter, or otherwise — receives the same normalized AST and executes it. Backends do not perform pattern recognition; the optimizer has already done that work.

This means interpreter backends benefit from optimization equally. A __native "array_map" node tells any backend to execute an efficient array traversal — a JIT generates tight native code for it, an interpreter calls a native function. Neither has to interpret a recursive letrec.

The optimizer fires once at load time. Backends execute the normalized AST on every call.


Evaluation Model ​

Programs are pure data transformations with no implicit side effects. Side effects (network, mutation) only occur at world boundaries through capabilities (call.method).

Evaluation order is strict (eager): arguments are evaluated before the operation.

do and let evaluate sequentially. All other operations do not guarantee evaluation order of arguments (implementations may parallelize).


Typed Constructors ​

Implementations should provide typed builder APIs that make it impossible to construct ill-typed expressions in the host language. Type errors surface at host compile time, before Marinada's own type checker runs.

The key idea: Expr<T> is a phantom/branded wrapper around a JSON array where T is the Marinada return type of the expression.

TypeScript ​

typescript
// Branded type — T is phantom, carries no runtime value
type Expr<T> = { readonly __marinada: T } & JsonValue

// Constructors enforce types at TS compile time
declare function add(a: Expr<number>, b: Expr<number>): Expr<number>
declare function get<T>(record: Expr<Record<string, T>>, key: string): Expr<T>
declare function literal(v: number): Expr<number>
declare function literal(v: string): Expr<string>
declare function literal(v: boolean): Expr<boolean>

// Type error caught by TS, not Marinada:
add(literal("hello"), literal(1))
//  ^^^^^^^^^^^^^^^^ error: Expr<string> not assignable to Expr<number>

Rust ​

rust
use std::marker::PhantomData;
use serde_json::Value;

struct Expr<T> {
    inner: Value,
    _phantom: PhantomData<T>,
}

fn add(a: Expr<f64>, b: Expr<f64>) -> Expr<f64> {
    Expr { inner: json!(["+", a.inner, b.inner]), _phantom: PhantomData }
}

// Wrong type = compile error

unknown maps to a top type in each host language (unknown in TS, a generic T with no bounds in Rust). ["untyped", expr] produces Expr<Unknown> — a distinct type that cannot be passed to typed ops without an explicit cast.

Plugin ops declare their input/output types as part of registration, so generated constructors for plugin ops are equally type-safe.


Effects ​

Marinada has algebraic effects. Effects unify what would otherwise be separate language mechanisms:

EffectModels
ErrorShort-circuit error propagation
AsyncSuspension and resumption
YieldGenerators and streams
CustomPlugin-defined effects

Effects are performed with ["perform", effect, payload] and handled with ["handle", expr, handler]. Handlers are user-defined — the language does not hardcode what effects mean.

There is no function coloring problem — effectful and pure functions are not syntactically distinguished. The type checker tracks which effects an expression may perform.

Effect-Aware Reactivity ​

Reactivity is effect-aware. When a reactive computation's dependencies change, the system cancels any in-flight effects from the previous run and restarts the computation. Behavior per effect:

  • Error — propagates through the reactive graph cleanly
  • Async — previous continuation is cancelled, computation restarts (analogous to Solid's createResource). The payload must be a fn that accepts an AbortSignal-compatible value and returns a Promise. The reactive layer creates an AbortController per evaluation, passes controller.signal to the fn, and calls controller.abort() before restarting. Any object structurally compatible with AbortSignal is accepted — no platform lock-in.
  • Yield — generator restarts from the beginning

This means reactive async data fetches, reactive streams, and reactive error propagation all work without restriction. Pure reactive computations (no effects) are a subset of this model.

Standard Effects ​

Error, Async, and Yield are standard library effects, not language primitives. They are defined using the same effect mechanism available to plugins.

Lambdas ​

json
["fn", ["x", "y"], ["+", ["var", "x"], ["var", "y"]]]

Parameter names in the binder list are bare strings; uses in the body are ["var", name]. Parameters are untyped by default (unknown). Optional type annotations:

json
["fn", [["x", "int"], ["y", "int"]], ["+", ["var", "x"], ["var", "y"]]]

Types ​

Type definitions (DUs, aliases) live in a separate schema/manifest, not inline in expressions. A Marinada module is a JSON object:

json
{
  "imports": [
    { "from": "lib:std",      "import": ["option", "result"] },
    { "from": "lib:matrix",   "import": ["MatrixEvent"] },
    { "from": "local:./my-types.json", "import": ["MyType"] },
    { "from": "https://example.com/types.json", "import": ["OtherType"] }
  ],
  "types": [
    { "name": "Shape", "variants": [
      { "tag": "Circle", "fields": [["radius", "float"]] },
      { "tag": "Rect",   "fields": [["width", "float"], ["height", "float"]] }
    ]}
  ],
  "main": ["Circle", 1.5]
}

Import Schemes ​

All imports produce a Module — the output format is identical regardless of scheme. The schemes differ in how the host resolves them, not in what they produce.

SchemeResolution strategy
lib:Named registry lookup (like npm package names)
local:Filesystem path resolution
https:Network fetch

lib: and local: are separate because they resolve differently: lib:foo asks "what is foo in the installed library registry?", while local:./foo.json asks "what is at this path?". This mirrors the require('lodash') vs require('./utils') distinction in Node — same module mechanism, different lookup strategy.

Resolution is delegated to the host via a resolver callback (scheme, path) => Promise<Module>. Marinada does not hardcode any resolution strategy. The host decides how lib:, local:, and https: are resolved in its context (filesystem, virtual FS, network, cache, etc.).

lib:std is the singular blessed standard library — the only lib: module guaranteed to be present in every Marinada environment. Other lib: modules are ecosystem libraries:

  • lib:std — standard functions (map, filter, reduce, compose, ...), standard DUs (option, result), standard effects (Error, Async, Yield)
  • lib:transport — HTTP, WebSocket, SSE, TCP capabilities
  • lib:protocol — JSON-RPC and other protocol primitives

Module Exports ​

Exports are explicit opt-in. Nothing is exported unless listed:

json
{
  "exports": ["MatrixEvent", "parseEvent", "MatrixSource"]
}

Standard library types (option<T>, result<T, E>) are defined in lib:std — no special cases in the language core.

Effects — Handler Syntax ​

json
["handle", ["var", "body"],
  [["Yield", "val", "k"], ["do", ["call", ["var", "collect"], ["var", "val"]], ["call", ["var", "k"], null]]],
  [["Error", "err", "k"], ["call", ["var", "log"], ["var", "err"]]],
  [["return", "x"], ["var", "x"]]]

Each clause binds the effect payload and the continuation k as explicit named parameters — first-class values, no magic. k can be called, passed around, stored.

Continuations are multi-shot — they can be called zero or more times. One-shot effects (Error, Async) simply never call k or call it exactly once. Yield calls k once per yielded value. The type system tracks continuation linearity where declared.

Effect Type Signatures ​

Effect types are inferred by default — the type checker tracks which effects an expression may perform without explicit annotation. Explicit annotation is optional:

json
["fn", [["x", "int"]], ["+", "x", 1], { "effects": [] }]
["fn", [["x", "int"]], ["fetch", "x"], { "effects": ["Async", "Error"] }]

Unannotated functions have their effect set inferred. Annotated functions are checked against the declared set.

Linearity ​

Marinada has opt-in linear and affine types. Linearity is a type modifier, applicable to any type:

ModifierConstraint
linearMust be used exactly once
affineMust be used at most once (droppable, not copyable)
(none)Unrestricted

Capabilities are linear by default — they cannot be silently copied or dropped. Continuations are linear by default. Regular data is unrestricted.

linear Cap<Network>
linear Continuation
affine FileHandle

The type checker enforces linearity only where annotated. No ergonomic cost for unrestricted values.

Linearity and unknown ​

unknown carries a linearity modifier. linear unknown is a distinct type from unknown. Passing a linear value where unknown is expected is a type error unless the target is linear unknown. Linear values cannot silently become unrestricted by casting.

Linearity Propagation through DUs ​

Linearity propagates structurally. If any variant of a DU contains a linear field, the DU itself is inferred as linear — no manual annotation required. The type checker computes this from the type definition.

type Handle = Open(linear FileHandle) | Closed
// Handle is inferred as linear — Open contains a linear field

Linearity and Reactivity ​

When a reactive computation re-runs due to a dependency change, any linear values from the previous run must be cleaned up before the re-run. Silent drop is not allowed — it would defeat linearity.

Linear types used in reactive computations must declare a destructor — a cleanup expression run before the value is discarded. The runtime calls it automatically on re-run.

json
{ "name": "FileHandle", "linear": true, "destructor": ["fn", ["h"], ["close", ["var", "h"]]] }

Affine types (at most once) may be silently dropped — dropping is explicitly permitted by the affine modifier.

Open Questions ​

  • Effect handler clause syntax — settled: [effect-name, ...payload-bindings, k], return clause has no k.