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, ...]whereopis a string naming the operation and args are expressions
["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.
["+", ["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 kPrior art: this mirrors JSONLogic's {"var": x} and CloudFormation's {"Ref": ...} — a plain-JSON, value-independent reference marker with no custom lexer.
__litis 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 literals. 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
| Type | Description |
|---|---|
int | 64-bit signed integer. JS impl uses BigInt. |
float | 64-bit IEEE 754 float. |
string | UTF-8 string |
boolean | true or false |
null | null — JSON-compatible nil value |
unknown | type not yet known |
Compound Types
| Type | Description |
|---|---|
array<T> | homogeneous sequence |
record<K, V> | map from K to V |
T | U | union |
bytes | raw 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.
["type", "Color", ["Red"], ["Green"], ["Blue"]]
["type", "Shape",
["Circle", ["radius", "float"]],
["Rect", ["width", "float"], ["height", "float"]]]Constructors:
["Red"]
["Circle", 1.5]Pattern matching via match — exact, exhaustive, deterministic:
["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:
["?", ["some-op-returning-result", ...]]letrec for mutually recursive definitions (required for tree traversal):
["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.
["untyped", ["get", ["var", "data"], ["var", "some-dynamic-key"]]]Type of ["untyped", expr] is unknown.
Built-in Operations
Variables
["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
["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
["+", a, b] ["-", a, b] ["*", a, b] ["/", a, b] ["%", a, b]All require number. Return number.
Comparison
["==", a, b] ["!=", a, b] ["<", a, b] [">", a, b] ["<=", a, b] [">=", a, b]== and != accept any type. Others require number. All return boolean.
Logic
["and", a, b] ["or", a, b] ["not", a]Require boolean. Return boolean.
Control Flow
["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
["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
["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
["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
["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 coercionsBitwise
["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
["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.
["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
| Type | Methods |
|---|---|
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:
{
"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:
- 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. - TCO — tail-recursive
letrec→["__loop", ...]nodes. This is the key normalization step: after TCO, all loops have a single canonical form. - Loop pattern recognition —
__loopnodes 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. - Loop fusion — adjacent
__nativearray operations fused to a single pass (gated on empty effect rows). - 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
// 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
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 errorunknown 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:
| Effect | Models |
|---|---|
Error | Short-circuit error propagation |
Async | Suspension and resumption |
Yield | Generators and streams |
| Custom | Plugin-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 cleanlyAsync— previous continuation is cancelled, computation restarts (analogous to Solid'screateResource). The payload must be afnthat accepts anAbortSignal-compatible value and returns aPromise. The reactive layer creates anAbortControllerper evaluation, passescontroller.signalto the fn, and callscontroller.abort()before restarting. Any object structurally compatible withAbortSignalis 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
["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:
["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:
{
"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.
| Scheme | Resolution 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 capabilitieslib:protocol— JSON-RPC and other protocol primitives
Module Exports
Exports are explicit opt-in. Nothing is exported unless listed:
{
"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
["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:
["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:
| Modifier | Constraint |
|---|---|
linear | Must be used exactly once |
affine | Must 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 FileHandleThe 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 fieldLinearity 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.
{ "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],returnclause has nok.