4.17. Intrinsics

Intrinsics are compiler-generated special functions. They are prefixed with @ and provide access to the Morloc runtime.

4.17.1. Reference table

Intrinsic Signature Description

@save

Int -> Str -> a -> <IO> (Try Str ())

Save a value as a morloc voidstar packet with a zstd compression level in 0..=9 (0 = uncompressed). Arguments are level, path, value. Round-trips through @load. I/O failure is an Err arm.

@savem

Str -> a -> <IO> (Try Str ())

Save a value to file in MessagePack format (portable, compact). Path-first for partial application (@savem path is a reusable sink). I/O failure is an Err arm.

@savej

Str -> a -> <IO> (Try Str ())

Save a value to file as plain JSON text (human-readable). Path-first for partial application. I/O failure is an Err arm.

@load

Str -> <IO> (Try Str a)

Load a value from file, auto-detecting the format (MessagePack, JSON, or morloc packet). Missing file, decode failure, and schema mismatch all come back as an Err arm.

@show

a -> Str

Serialize any value to a JSON string. Pure — no effect row.

@read

Str -> Try Str a

Parse a JSON string into a value of the expected type. Pure — parsing touches nothing, so this composes under map. Parse failure is an Err arm.

@open

Str -> <IO> (Try Str a)

Open a stream file; a is resolved by inline ascription to IFile a, IStream a, or OStream a. See Random access and streaming. Missing, unreadable, or non-packet files give an Err arm. Opening /dev/stdin reads process stdin: as IStream a it routes to the stdin channel (like @stdin); as IFile a it gives an Err arm, since a pipe is not seekable.

@close

a -> <IO> ()

Given a stream file handle, close it — for an OStream this writes the final footer — and release the handle’s slot. Given a Str path (as produced by the whole-list @with/@render gather via @tmpfile), unlink that temporary file instead. Only files registered by the gather are removed; passing any other path is rejected rather than deleting it.

@tmpfile

<IO> (Try Str Str)

Create a fresh empty temporary file and return its path. Used by the whole-list @with/@render gather to stage a stream on disk before applying a handler to the complete data; the file is removed afterward with @close. I/O failure is an Err arm.

@fschema

Str -> <IO> (Try Str Str)

Read a stream file’s element schema without binding a typed handle. Useful for runtime schema discovery. Missing or malformed files give an Err arm.

@flen

IFile a -> <IO> (Try Str Int)

Total element count of an IFile, read from the file’s footer.

@write

Int -> OStream a -> [a] -> <IO> (Try Str ())

Append a list of elements to an OStream. The first argument is a zstd compression preset in 0..=9 (0 = no compression); see Compression for the level table. I/O failure (disk full, broken pipe) is an Err arm.

@flush

OStream a -> <IO> (Try Str ())

Force buffered elements to disk as a sub-packet boundary.

@append

Str -> <IO> (Try Str (OStream a))

Open a stream file for further writes, creating it if it is not there yet. Schema mismatch is an Err arm at open time, before any bytes are written.

@concat

[Str] -> Str -> <IO> (Try Str ())

Byte-level concatenate compatible stream files into a destination via sendfile. The destination is replaced atomically and may itself be one of the sources.

@next

IStream a -> <IO> (Try Str [a])

Pull the next sub-packet’s elements from an IStream. Yields Ok [] at EOF. Mid-stream decode failures are an Err arm.

@stream

IFile a -> <IO> (IStream a)

Derive a forward-walking IStream from an open IFile. The two share the underlying file but have independent cursors.

@stdin

<IO> (Try Str (IStream a))

Open process stdin as a typed IStream of morloc binary packets. Element type set by inline ascription. See Random access and streaming. A second @stdin in the same nexus gives an Err arm via the uniqueness guard; read-time failures surface at @next.

@stdout

<IO> (OStream a)

Open process stdout as a typed OStream of morloc binary packets. Element type set by inline ascription.

@stderr

<IO> (OStream a)

Open process stderr as a typed OStream of morloc binary packets. Element type set by inline ascription. Useful for structured diagnostics that downstream tools can parse.

@collect

(([a] -> <IO, e> ()) -> <IO, e> ()) -> <IO, e> ()

Drive a streaming-output command. Takes a producer that is handed a sink ([a] → <IO, e> ()); @collect supplies the default sink — write each batch to the nexus-formatted @stdout — and manages the stream lifecycle. Formatter directives (@with / @render, with the @stream modifier for per-batch application) rewrite the sink to transform or gather the stream per CLI flag.

@tell

<IO> U64

The number of elements written so far to the current output stream. Lets an offset-form formatter handler (U64 → [a] → …​) annotate each batch with its running position in the stream.

@hash

a -> Str

Hash a value via MessagePack serialization (xxhash), returns a 16-character hex string

@version

Str

The compiler version string (resolved at compile time)

@compiled

Str

The compilation timestamp (resolved at compile time)

@lang

Str

The canonical language identifier of the pool where the expression is evaluated — the name field from lang.yaml ("py", "cpp", "r", …​; "morloc" at the nexus level)

@datafile

Str -> Str

Resolve a relative path to the installed data file location (resolved at compile time)

@schema

a -> Str

The serialization schema string for the given type

@typeof

a -> Str

The morloc abstract type name for the given type, e.g. "Int", "[Str]", "?Real", "(Int, Str)"

@throw

Str -> a

Raise a MorlocException with the given message. Emits a native raise/throw/stop in the target language and never returns; the return type is polymorphic so @throw can inhabit any branch of a conditional. It has no effect row, because a computation that does not return has nothing for one to describe.

@try

<e> a -> <e> (Try Str a)

Evaluate the expression under a language-native try/catch. A completed evaluation is Ok, an escaping exception is Err carrying its message. The argument’s effect row passes through unchanged; the argument may be pure, in which case so is the result.

Several intrinsics are polymorphic in their data argument: @save, @savem, @savej, @write, @hash, @show, @schema, and @typeof accept a value of any type. @load, @read, and @next return a value of any type, inferred from context. @stdin, @stdout, @stderr, @open, and @append are polymorphic in their handle’s element type, which is resolved by inline ascription at the open site. @collect is polymorphic in the stream element type a, read off the sink its producer is handed. @throw is polymorphic in its return type because it never returns — the return slot unifies with whatever the surrounding context expects. @try passes its argument’s effect row through unchanged.

Two patterns run through the table. Anything that touches the world carries <IO>, and anything that can fail returns a Try (see Failure is not an effect): a missing file, a full disk, a broken pipe or a decode mismatch arrives as an Err arm rather than as an effect label. Most entries have both. The exceptions on the failure side are @close, @tell, @stream, @stdout and @stderr, which do setup, teardown or bookkeeping with no failure mode addressable from morloc code; @read is the exception on the other side, fallible but pure, because parsing a string touches nothing. The remaining intrinsics (@version, @compiled, @lang, @datafile) are compile-time constants, and @hash, @show, @schema and @typeof are pure functions of their argument’s type or serialized bytes.

4.17.2. Hashing

@hash computes a fast, non-cryptographic hash (xxhash) of any value. The value is first serialized to MessagePack internally, then hashed. The result is a 16-character hexadecimal string.

module main (hashInt, hashStr)

import root-py (id)

hashInt :: Int -> Str
hashInt x = @hash (id x)

hashStr :: Str -> Str
hashStr x = @hash (id x)
$ ./intrinsics hashInt 1
"6fffcb30bbcc5a72"
$ ./intrinsics hashStr 1
"06e0f1375b38be15"

The two differ because the integer 1 and the string "1" have different MessagePack encodings, which is the point of the paragraph below.

Hashing is deterministic: the same value always produces the same hash. Two values of different types may hash differently even if they look similar (e.g., the integer 1 and the string "1"), because their MessagePack serializations differ.

4.17.3. Compile-time constants

The @version, @compiled, and @lang intrinsics are resolved at compile time. They can be used anywhere a Str value is expected.

module main (info)

import root-py (id)

info :: [Str]
info = id [@version, @compiled, @lang]
$ ./intrinsics info
["0.100.2","2026-09-02T12:58:04Z","morloc"]

The @lang value depends on where the expression is evaluated. When the list literal above is assembled at the nexus level (not inside a sourced function), @lang resolves to "morloc". To observe the language-pool identifier, pass @lang into a sourced function and let it be evaluated inside that pool: the value will be that pool’s canonical language identifier — the name field from its lang.yaml ("py", "cpp", "r", …​).

@lang deliberately returns this short canonical identifier, not a human-facing display name like "Python3" or "C++". Intrinsics are low-level primitives where stability outweighs presentation: the lang.yaml name is the guaranteed-unique, stable identifier for a language backend, so it is the correct value for conditional logic and tooling. Map it to a prettier label yourself if you need one.

4.17.4. Saving and loading data

The @savem and @savej intrinsics write a single value to a file path, and @load reads it back. Together they provide a type-safe file persistence mechanism for one-shot writes. For multi-element accumulation use an OStream and @write instead (see Random access and streaming).

@savem uses MessagePack, which is compact and portable across different machines and architectures. @savej writes plain JSON, which is human-readable and can be edited by hand or consumed by other tools.

@load auto-detects the file format. Files written by @savem carry a small header that identifies them as MessagePack. If no header is present, @load tries to parse the file as JSON. @load also recognises morloc stream and voidstar packets, so any file produced by the morloc runtime round-trips through it.

@load returns <IO> (Try Str a). A missing file, decode failure, or schema mismatch against the caller’s expected type all come back as an Err arm rather than an exception — see Failure and recovery.

Here is a basic round-trip example:

module main (roundTrip)

import root
import root-py (id)

roundTrip :: Int -> Str -> <IO> Int
roundTrip x path = do
  @savem path (id x)
  Ok v <- @load path
  v

The bare @savem writes the integer to the given path; nothing reads its result, so a write failure stops the block there (see A bare statement checks its result). Ok v ← @load path reads the value back and unwraps it, throwing if the load failed. The signature is plain <IO> Int, because neither failure survives as a value.

$ ./sv roundTrip 42 tmp.bin
42

import root is doing work here: Try and its two constructors are declared in the standard library, so a module that names Ok or Err needs it.

You can also use @savej when you want the output to be readable:

module main (saveReadable)

import root
import root-py (id)

saveReadable :: Str -> [Str] -> <IO> ()
saveReadable path xs = do
  @savej path (id xs)
  ()

The resulting file is plain JSON that can be inspected in any text editor. The trailing () is not decoration: a do-block’s last statement is its return value, so leaving @savej there would make the block return the Try rather than discharge it. A do-block does not return a Try gives the three ways to write this and when each is right.

4.17.5. Caching with @savem and @load

A common pattern is to check whether a cached result exists before recomputing it. @load gives an Err arm when the file is missing (or on any decode failure), so match on the result and compute in the Err arm:

module main (cachedResult)

import root
import root-py (id)

source Py from "compute.py" ("expensiveComputation")
expensiveComputation :: Int -> Int

cachedResult :: Int -> Str -> <IO> Int
cachedResult x cachePath = do
  cached <- @load cachePath
  match cached
    | (Ok v) = do v
    | (Err _) = do
        let fresh = expensiveComputation x
        @savem cachePath (id fresh)
        fresh
$ ./sv cachedResult 12 c1.bin
144
$ ./sv cachedResult 12 c1.bin
144

On the first call the cache file does not exist, so the Err arm runs expensiveComputation, saves the result and returns it. On the second the Ok arm returns the stored value and the computation never runs. An arm may be a whole do-block, which is what makes the recomputation lazy: only the arm that is taken evaluates. Both arms of a match have one type, so once the Err arm is a do-block the Ok arm wraps its value in do too (see The rules); a bare v there is a type error.

The bare @savem inside the Err arm still checks itself, so a cache that cannot be written stops the program rather than silently returning an uncached value. If you would rather carry on, bind it: _ ← @savem cachePath (id fresh).

You can also use @hash to build content-addressed caches where the cache path depends on the input:

module main (hashedCache)

import root
import root-py

source Py from "compute.py" ("expensiveComputation")
expensiveComputation :: Int -> Int

hashedCache :: Int -> <IO> Int
hashedCache x = do
  let key = @hash (id x)
  let cachePath = "/tmp/cache_" <> key <> ".bin"
  cached <- @load cachePath
  match cached
    | (Ok v) = do v
    | (Err _) = do
        let fresh = expensiveComputation x
        @savem cachePath (id fresh)
        fresh

Each distinct input gets its own cache file, keyed by the xxhash of its serialized form.

4.17.6. Accessing installed data files

The @datafile intrinsic resolves a relative file path to its location in the installed program directory. When you compile with morloc make --install, source files and data files listed in package.yaml are copied into the install directory. At runtime, these files are no longer at their original paths. @datafile bridges this gap by resolving the path at compile time.

module main (readConfig)

import root-py

source Py from "config.py" ("loadConfig")
loadConfig :: Str -> Str

readConfig :: Str
readConfig = loadConfig (@datafile "defaults.json")

Here @datafile "defaults.json" evaluates to the absolute path where defaults.json is installed (for example, ~/.local/share/morloc/exe/main/defaults.json). The Python function receives this path as a plain string and can open the file normally.

When running without --install (plain morloc make), @datafile returns the relative path unchanged, so the program works from the project directory as expected.

Note Source functions that need data files should accept the path as a parameter rather than hardcoding relative paths. This keeps data dependencies explicit in the type signature and ensures files are found correctly whether the program is run from the project directory or installed.

4.17.7. Type introspection

The @schema and @typeof intrinsics return information about how the compiler represents a type. The value argument is not evaluated at runtime — only its type matters.

module main (showSchema, showType)

import root-py (id)

showSchema :: Int -> Str
showSchema x = @schema (id x)

showType :: Int -> Str
showType x = @typeof (id x)
$ ./intrinsics typeofInt
"Int"
$ ./intrinsics typeofList
"[Str]"
$ ./intrinsics typeofOpt
"?Real"
$ ./intrinsics typeofTup
"(Int, Str)"

@typeof returns the morloc abstract type name (the same way the type would be written in a signature): "Int", "Str", "Real", "Bool", "[Int]", "?Int", "(Int, Str)", and so on. It does not return the language-native type name in the current pool.

@schema returns the internal serialization schema string used by the compiler for MessagePack and binary serialization. The encoding is short, byte-oriented, and stable for a given compiler version. The alphabet:

Schema fragment Type

j

Int (default variable-width integer)

i1 / i2 / i4 / i8

I8 / I16 / I32 / I64

u1 / u2 / u4 / u8

U8 / U16 / U32 / U64

f4 / f8

F32 / F64 (and Real, which maps to f8)

b

Bool

s

Str

z

Null / () (Unit)

?X

Optional X — ? prefix followed by an inner schema

aX

List X (and Array, Deque, Vector) — a prefix followed by an inner schema; fixed-dim arrays append :N

tN X1X2…​XN

Tuple of N elements — t, a length code, then one schema per element, run together with no separators

mN <len><key>X…​

Named record of N fields — m, a field count, then (key length, key text, schema) per field, with no separators

T / T:N …​

Arrow table primitive; bare T is row-polymorphic, T:N declares N required columns

*

Unknown (unresolved) type

<typename>…​

Optional concrete-type hint prefix (e.g., <Money>j for type Money = Int)

There are no separators anywhere in the encoding. Reading a few real ones is the quickest way to internalize it:

$ ./schemas schemaOf
["t2js","<dict>m24names3agej","aaj","i4","u1","f4"]
Those are, in order: (Int, Str); a record `P {name

Str, age :: Int}` mapped to a Python dict; [[Int]]; I32; U8; and F32. Take the record apart — <dict> is the concrete-type hint, m2 says two fields, 4name is a four-character key followed by s for its Str value, and 3age is followed by j for its Int.

@schema is primarily useful for debugging and for cross-language tools that inspect morloc wire formats.

4.17.8. Failure and recovery

Failure in Morloc is a value, not an effect: a fallible operation returns Try e a and the caller matches on it. Failure is not an effect makes that case; this section covers the two intrinsics that sit at its edges. @throw produces a failure that is not a value — a native exception that unwinds — and @try turns one back into a value.

@throw: abandon the computation

@throw raises an exception from morloc code. It generates a native raise/throw/stop statement in the language of the pool where the expression is evaluated, halting execution and unwinding the call stack.

The signature is Str → a. The message is any Str expression, so string interpolation with #{expr} works naturally. The return type is polymorphic: since @throw never returns a value, its return type can unify with whatever the surrounding context expects, letting @throw inhabit any branch of a conditional. There is no effect row, for the same reason — a computation that does not return has nothing for a row to describe.

module main (tryRead)

import root
import root-py

source Py from "reader.py" ("openReader", "readerOk")

openReader :: Str -> <IO> Int
readerOk   :: Int -> Bool

tryRead :: Str -> <IO> Int
tryRead path = do
  handle <- openReader path
  ? readerOk handle = handle
  : @throw "failed to open reader for #{path}"
$ ./intr tryRead a.ok
1
$ ./intr tryRead a.bad
Error: run failed
failed to open reader for a.bad
  at tryRead [py] (mid=1, intr.loc:1:14)

Here @throw occupies one arm of the ?/: conditional and the other arm returns an Int. The polymorphic return type unifies with Int, and tryRead’s signature is plain `<IO> Int: it opens a file, and the way it fails does not show up in its type.

The generated code depends on the target language: in Python @throw msg becomes raise MorlocException(msg), in C++ it becomes throw MorlocException(msg), in R it becomes stop(structure(class=c("MorlocException", "error", "condition"), list(message=msg, call=NULL))). Each pool defines MorlocException as a subclass of the language’s native runtime error type, so existing try/catch/tryCatch scaffolding at the pool boundary catches it without any user-side setup.

When @throw is invoked from the nexus itself (not inside a pool-bound function), it raises a nexus-side MorlocError with the same message and exits the program non-zero.

Use @throw when there is nothing sensible to return and the caller has no decision to make. When the caller does have a decision to make, return a Try instead.

@try: turn a raise back into a value

@try evaluates its argument with the target language’s exception machinery armed. Its signature is:

@try :: <e> a -> <e> (Try Str a)

A completed evaluation is Ok; an exception that escapes the argument is Err, carrying the exception’s message. The argument’s effect row passes through untouched, and the argument may be pure, in which case so is the result — @try is not itself an effect.

safeRead :: Str -> <IO> Int
safeRead path = do
  r <- @try (tryRead path)
  match r | (Ok v) = v | (Err _) = 0
$ ./intr safeRead a.ok
1
$ ./intr safeRead a.bad
0

The point of a value rather than a fallback expression is that the failure is now something you can read. describeRead reports it rather than swallowing it:

describeRead :: Str -> <IO> Str
describeRead path = do
  r <- @try (tryRead path)
  match r
    | (Ok v) = "opened, handle #{@show v}"
    | (Err e) = "could not open: #{e}"
$ ./intr describeRead a.bad
"could not open: failed to open reader for a.bad"

Anything may be wrapped. Unlike the intrinsic it replaces, @try demands no marker on its argument saying that failure is possible, because no such marker exists any more: a sourced function raises in its own language whether or not its signature hints at it, and @try is how you find out. Wrapping an argument that cannot fail is harmless — the result is always Ok — and wasted words.

At runtime, @try uses the language’s native try/catch machinery. In Python it becomes a try/except Exception, in C++ try { } catch (const std::exception&), in R tryCatch(…​, error = …​). Any exception is caught: MorlocException raised by @throw, foreign-library errors (a Python KeyError, a C++ std::out_of_range), and cross-pool fail packets, which the calling pool re-throws as a native exception.

Chaining attempts

Because an arm of a match may be a whole do-block, and only the arm that is taken evaluates, fallible attempts nest without any special construct:

robustRead :: Str -> <IO> Int
robustRead path = do
  a <- @load "cache/#{path}"
  match a
    | (Ok v) = do v
    | (Err _) = do
        b <- @load "disk/#{path}"
        match b
          | (Ok v) = do v
          | (Err _) = do
              c <- @load "net/#{path}"
              unwrap c

Each load runs only if the one before it failed, and each Ok arm wraps its value in do for the reason given under the cache pattern above. With none of the three files present, every attempt fails and the last error is the one that escapes, because unwrap throws the Err message it is handed:

$ ./chain robustRead x
Error: evaluation failed: @load: failed to load 'net/x'

Put a plain value in the innermost arm instead of unwrap c and the chain cannot fail at all.

Caveats

@try intercepts exceptions raised within the calling process. A pool that is killed outright — an out-of-memory kill, an external kill -9 — is not catchable, because the crash reaches the nexus as a socket error rather than a language-native exception.

@throw accepts only a Str. Throwing a structured value is not supported: the payload is rendered into the traceback at the throw site and does not survive as a value, so there would be nothing on the other side to match on. Render it yourself with @show if you need more than a message.