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 a value as a morloc voidstar packet with a zstd compression level in |
|
|
Save a value to file in MessagePack format (portable, compact). Path-first for partial application ( |
|
|
Save a value to file as plain JSON text (human-readable). Path-first for partial application. I/O failure is an |
|
|
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 |
|
|
Serialize any value to a JSON string. Pure — no effect row. |
|
|
Parse a JSON string into a value of the expected type. Pure — parsing touches nothing, so this composes under |
|
|
Open a stream file; |
|
|
Given a stream file handle, close it — for an |
|
|
Create a fresh empty temporary file and return its path. Used by the whole-list |
|
|
Read a stream file’s element schema without binding a typed handle. Useful for runtime schema discovery. Missing or malformed files give an |
|
|
Total element count of an |
|
|
Append a list of elements to an |
|
|
Force buffered elements to disk as a sub-packet boundary. |
|
|
Open a stream file for further writes, creating it if it is not there yet. Schema mismatch is an |
|
|
Byte-level concatenate compatible stream files into a destination via |
|
|
Pull the next sub-packet’s elements from an |
|
|
Derive a forward-walking |
|
|
Open process stdin as a typed |
|
|
Open process stdout as a typed |
|
|
Open process stderr as a typed |
|
|
Drive a streaming-output command. Takes a producer that is handed a sink ( |
|
|
The number of elements written so far to the current output stream. Lets an offset-form formatter handler ( |
|
a -> Str |
Hash a value via MessagePack serialization (xxhash), returns a 16-character hex string |
|
|
The compiler version string (resolved at compile time) |
|
|
The compilation timestamp (resolved at compile time) |
|
|
The canonical language identifier of the pool where the expression is evaluated — the |
|
|
Resolve a relative path to the installed data file location (resolved at compile time) |
|
|
The serialization schema string for the given type |
|
|
The morloc abstract type name for the given type, e.g. |
|
|
Raise a |
|
|
Evaluate the expression under a language-native try/catch. A completed evaluation is |
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.
|
|
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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Tuple of |
|
Named record of |
|
Arrow table primitive; bare |
|
Unknown (unresolved) type |
|
Optional concrete-type hint prefix (e.g., |
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; andF32. Take the record apart —<dict>is the concrete-type hint,m2says two fields,4nameis a four-character key followed bysfor itsStrvalue, and3ageis followed byjfor itsInt.
@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.