4.16. Optional types

Every language needs a way to say "no value here". Query a database for a row that does not exist, or read a parameter that was never set, and something has to come back. Python has None, R has NULL, JSON has null, and C++ solves it in the library with std::optional<T>.

Morloc’s principle is that sourced functions stay idiomatic, so it needs a mechanism that lowers to each language’s own answer while staying consistent across the boundary. That is what the optional type is for.

4.16.1. Syntax

The ? prefix marks a type as optional, and Null is the absent value. ?Int is an integer that might be absent, ?Str a string that might be. The prefix applies to any type, including lists (?[Int]) and records (?Person).

--' Get the first element of a list, or nothing
safeHead :: [Int] -> ?Int

testNull :: ?Int
testNull = Null
Note Morloc writes Null capitalized in source, following the convention that constructors start with an uppercase letter — the same as True and False. In JSON output it serializes as lowercase null, per the JSON standard.

4.16.2. Working with optional values

Functions that produce or consume optionals are sourced like any others:

main.loc
module main (testSafeHead, testSafeHeadEmpty, testFromNull)

import root-py

safeHead :: [Int] -> ?Int
safeHead xs
  ? length xs == 0 = Null
  : .[0] xs

source Py from "main.py" ("default")
default :: a -> ?a -> a

testSafeHead :: ?Int
testSafeHead = safeHead [10, 20, 30]

testSafeHeadEmpty :: ?Int
testSafeHeadEmpty = safeHead []

testFromNull :: Int
testFromNull = default 0 Null

The Python side handles None the way Python always does:

main.py
def default(default_val, x):
    if x is None:
        return default_val
    return x
$ ./main testSafeHead
10
$ ./main testSafeHeadEmpty

$ ./main --keep-null testSafeHeadEmpty
null
$ ./main testFromNull
0
Note When an exported function’s top-level result is Null (or ()), the nexus prints an empty line rather than the literal null. Printing null would be noisy in a CLI tool, and a downstream consumer that ingested a stray null line could choke on it or, worse, treat it as a valid record. Pass --keep-null when you want the literal emitted, as above.

The same shape works in the other languages. In C++, using std::optional:

#include <optional>

template <class T>
T orDefault(T default_val, const std::optional<T>& x) {
  if(x.has_value()){
    return x.value();
  } else {
    return default_val;
  }
}
Note The helper is orDefault, not default. default is a C++ keyword, and a function so named will not compile.

And in R, using NULL:

orDefault <- function(default_val, x){
  if(is.null(x)){
    return(default_val)
  } else {
    return(x)
  }
}

4.16.3. Optional record fields

Record fields may be optional, which is what you want for data with missing or unknown values. The where form below is an alternative syntax for record declarations, equivalent to the brace syntax in Records:

record Person where
  name :: Str
  age :: ?Int

record Py => Person = "dict"

source Py from "foo.py" ("makePerson")
makePerson :: Str -> ?Int -> Person

alice :: Person
alice = makePerson "Alice" 30

bob :: Person
bob = makePerson "Bob" Null
$ ./person alice
{"name":"Alice","age":30}
$ ./person bob
{"name":"Bob","age":null}

4.16.4. Optionals across languages

An optional produced in one language can be consumed in another with no interop code from you:

-- C++ produces an optional value
source Cpp from "foo.hpp" ("cSafeDiv")
cSafeDiv :: Int -> Int -> ?Int

-- Python consumes it
source Py from "foo.py" ("pFromNull")
pFromNull :: Int -> ?Int -> Int

testCppToPy :: Int
testCppToPy = pFromNull (-1) (cSafeDiv 10 3)

testCppToPyNull :: Int
testCppToPyNull = pFromNull (-1) (cSafeDiv 10 0)
$ ./optional testCppToPy
3
$ ./optional testCppToPyNull
-1

The compiler generates the serialization at each boundary. A std::nullopt in C++ becomes JSON null, which Python reads as None.

4.16.5. Implicit coercion

Morloc coerces a plain value to an optional wherever the context wants one, so you never write a wrapper at the call site:

source Py from "foo.py" ("addOpt")
addOpt :: ?Int -> ?Int -> ?Int

-- both arguments are plain Int, coerced to ?Int
testCoerceAddOpt :: ?Int
testCoerceAddOpt = addOpt 3 4

-- the second argument (42) is Int, coerced to ?Int
testCoerceArg :: Int
testCoerceArg = pFromNull 0 42
$ ./optional testCoerceAddOpt
7
$ ./optional testCoerceArg
42

Coercion crosses language boundaries too. A C++ function returning a plain Int can feed a Python parameter typed ?Int:

source Cpp from "foo.hpp" ("cAddOne")
cAddOne :: Int -> Int

testCppIntToPyOpt :: Int
testCppIntToPyOpt = pFromNull 0 (cAddOne 41)
$ ./optional testCppIntToPyOpt
42

4.16.6. Nested optionals are idempotent

?(?T) parses and typechecks, but at run time it collapses to a single ?T. There is one Null, and no way to tell an "outer Null" from an "inner Null". This is deliberate.

The reason goes back to why ? is a language primitive rather than a library type like C++'s std::optional. ? must lower to each target language’s own missing value: None in Python, NULL in R, std::optional<T> in C++. In Python and R — and in most dynamic languages — that value is structureless. There is no mechanism for telling an outer None from an inner one; both are the same singleton. If Morloc allowed two distinguishable null levels, the semantics would diverge across backends, since C++ could fake it with nested std::optional and Python could not. That would break the portability ? exists to provide.

So ?T, ?(?T), and ?(?(?T)) all serialize to the same wire format and the same runtime representation in every backend:

collapsed1 :: ?(?Int)
collapsed1 = Null

collapsed2 :: ?(?Int)
collapsed2 = 7   -- treated identically to (7 :: ?Int)
$ ./optional collapsed1

$ ./optional collapsed2
7

If you genuinely need layered nullability — telling "the lookup failed" apart from "the lookup succeeded but the field was unset" — encode the distinction in a type of your own:

record LookupResult = LookupResult
  { tableMissing :: Bool   -- step 1 failure
  , fieldMissing :: Bool   -- step 2 failure
  , value :: ?Int          -- present when both succeeded
  }
Note Sum types — tagged unions such as data Result = Found Int | Missing — are planned but not yet supported. Their cross-language design is the open problem, since not every backend has a first-class sum representation.