The Mond Programming Language
Mond is an experimental functional language with a Lisp-inspired syntax and ML-style static types that targets the BEAM.
Mond’s core features are:
- immutability
- Hindley-Milner type system that infers types, no hints or signatures.
- strong and static typing
- expressive type system
- no function coloring
- errors as values
- no panics or exceptions
- friendly and useful compiler errors
- targets the BEAM
Mond draws inspiration from a variety of programming languages but chiefly from:
- Rust
- Clojure
- OCaml
Getting Started
This book is intended to be a semi-comprehensive guide to the language. In this section we’ll cover everything you need to get started learning.
Installation
Currently, the only way to install Mond is from source.
Mond’s compiler is written in Rust. To install it, you’ll need a Rust toolchain. To run Mond code, you’ll need to install erlang, and to create a release you’ll need rebar3.
To install everything on Arch Linux, run the following:
sudo pacman -S rustup erlang rebar3
Or on macOS:
brew install rustup erlang rebar3
Once you have those installed, you have two options:
Clone and install
git clone git@github.com:benjaminjellis/mond.git
cd mond
cargo install --path bahn
Or without cloning
cargo install --git https://github.com/benjaminjellis/mond.git --tag 0.0.5 bahn
To verify installation
bahn --help
which should print something like the below
the build tool for the mond programming language
Usage: bahn <COMMAND>
Commands:
run
test
deps
lsp
format
new
build
release
clean
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help
-V, --version Print version
Bahn
Mond is the language and bahn is the CLI / build tool. Bahn seeks to behave just like Cargo does for Rust. To get started, simply run:
bahn new hello_world
This will create a new directory hello_world. You can then run:
cd hello_world
bahn run
And you should see “Hello World” printed to stdout.
If you look in src/main.mond, you’ll see this:
(use std)
(let main {}
(io/println "Hello, world!"))
In the next section, we’ll go through the language
Language tour
Source files use the extension .mond and Mond source is written as s-expressions. This next section is intended to give you a high-level overview of syntax.
Note: Mond is still in the early stages of development and syntax is likely to change.
Primitive Types
Mond has Int, Float, String, Bool, and Unit as primitive types.
Int and Float
Int and Float operators are distinct. Float operators use a . suffix.
We can see this looking at the following two functions (more on those soon).
(let add_ints {a b} (+ a b)) ;; Int -> Int -> Int
(let add_floats {a b} (+. a b)) ;; Float -> Float -> Float
+. works only for Float and + works only for Int.
% is available for integer modulo:
(let mod_two {x} (% x 2))
Signed numeric literals are also supported:
(let negative_int {} -1)
(let negative_float {} -1.5)
Numeric separators with _ are supported for readability:
(let million {} 1_000_000)
(let tax_rate {} 12_500.25)
Bool
Bool literals are True and False.
(let always_true {} True)
(let always_false {} False)
Boolean operators are and, or, and not:
(let can_enter {has_ticket is_member}
(and has_ticket (not is_member)))
String
String literals are enclosed in double quotes.
(let just_hello {} "Hello")
Functions
Mond lets users define functions at the top level of a file.
Let’s say we have a file called my_file.mond with the following contents:
(let square {x}
(* x x))
This is a perfectly valid Mond file which:
- defines a function
square - that takes one arg
x(arguments to functions live inside the curly brackets{}) - The body of the function is then defined in the second set of round brackets. This is how you invoke functions in
Mond, using Polish Notation. The function comes first then the arguments - returns
xsquared asMonduses implicit return
So if you wanted to invoke square you could define a main function like this and call square in the body.
(let main {}
(square 10))
Mond supports self-recursive functions (with tail call optimisation). So we can write a function to calculate the factorial of a number as below.
(let factorial {n}
(if (= n 0)
1
(* n (factorial (- n 1)))))
By convention, function names are snake_case.
Use pub let to export a function from a module:
(pub let square {x}
(* x x))
Mond also has anonymous functions using f:
(let apply {func x}
(func x))
(let main {}
(apply (f {n} -> (+ n 1)) 10))
Mond uses a Hindley-Milner type system, this means that all types are inferred at compile time and no type signatures are required. Because of this Mond doesn’t actually support type signatures.
Local Bindings
You can bind local variables inside functions using (let [name value] body). Bindings can be chained and each name is in scope for the body of the local binding.
As an example below we have a function called circle_area which:
- takes one argument
r - binds data to two local variables
piandr_squsing a localletbinding - returns the area of the circle
(let circle_area {r}
(let [pi 3.14159
r_sq (*. r r)]
(*. pi r_sq)))
Note:
- like function names local
letbindings, by convention, usesnake_caseidentifiers - this function has the type
Float -> FloatwhichMondinfers because of the use of*.
Types
Mond is statically typed. There are two forms of types that you can express:
Sum / Variant Types
e.g. Result and Option
(type ['a] Option
[None
(Some ~ 'a)])
(let greet {name}
(match name
None ~> (io/println "Hello, stranger!")
(Some n) ~> (io/println (string/append "Hello, " n))))
Sum types have two components:
- the name of the type (
Optionin the case above) - the constructors (
NoneandSome)
The constructors can be nullary (like None) or encompass data (like Some). The ~ is used to provide a type that Some encompasses. This can be a concrete type like Int or, as above, it could be a polymorphic type like 'a. By convention variant / sum type constructors use PascalCase identifiers.
Constructors can also take multiple positional payload values:
(type IpAddress
[(IpV4 ~ Int Int Int Int)
(IpV6 ~ Int Int Int Int Int Int Int Int)])
(let octet_sum {ip}
(match ip
(IpV4 a b c d) ~> (+ (+ a b) (+ c d))
(IpV6 _ _ _ _ _ _ _ _) ~> 0))
Product / Record Types
(type Point
[(:x ~ Int)
(:y ~ Int)])
(let origin {} (Point :x 0 :y 0))
(let x_coord {p} (:x p))
Record types have fields which, by convention, are snake_case identifiers. Each is prefixed with :. You can access a field with (:field record). ~ is again used to specify what type each field is. Just like sum/variant types, you can use a polymorphic type like 'a.
You can also destructure records in match using named field patterns (for example (Person :age age)).
e.g.
(type ['a] Point
[(:x ~ 'a)
(:y ~ 'a)])
By convention all type names are PascalCase identifiers.
Record update
Mond allows for easy updating of record with the keyword with.
(let update_x {point new_x}
(with point
:x new_x))
(let main {}
(let [my_point (Point :x 10 :y 12)]
(io/debug my_point)
(let [updated_point (update_x my_point 50)]
(io/debug updated_point))))
Field Constraints
When multiple records share a field label (for example both have :selector), Mond now infers a field constraint instead of committing to one record too early.
(type ContinuePayload [(:selector ~ Int)])
(type Initialised [(:selector ~ Int)])
(let read_selector {x} (:selector x))
The inferred type for read_selector is shown as a qualified type:
HasField :selector 'a Int => 'a -> Int
This means read_selector works for any record type that has a :selector field of type Int.
Migration Notes
- If you see
unsatisfied field constraint ..., no visible record instance matches that field label/type at the call site. - If you see
ambiguous field constraint ..., the value is still too polymorphic; add a concrete type constraint (for example withmatch, constructor use, or a more specific helper argument type). - LSP hover, completion details, and signature help now include these inferred constraints so you can see exactly what a polymorphic field helper requires.
Lists
List literals are created using square brackets as below. Like all types in Mond, they are immutable.
(let numbers {} [1 2 3 4 5])
Lists are homogeneous, so all items must have the same type.
If / else
If/else can be used for control flow as below.
(let abs {x}
(if (< x 0)
(- 0 x)
x))
if let
Use if let when you want to test one pattern and fall back to an else branch.
The syntax is:
(if let [<pattern> <value>]
<then-branch>
<else-branch>)
Example:
(let selector_or_default {initialised subject}
(if let [(Some selector) (:selector initialised)]
selector
(process/select (process/new_selector) subject)))
if let is shorthand for a match with one explicit arm and _ fallback:
(if let [(Some x) maybe]
x
0)
;; equivalent to
(match maybe
(Some x) ~> x
_ ~> 0)
Match
You can pattern match with the keyword match. Use ~> to separate each pattern from its result. _ is a wildcard.
Pattern matching works on single variables
(let describe {n}
(match n
0 ~> "zero"
1 ~> "one"
_ ~> "many"))
… on multiple variables
(let two_values {x y}
(match x y
10 12 ~> (io/println "matched")
_ _ ~> (io/println "not matched")))
or on lists with the cons operator
(let iterate {list}
(match list
[] ~> (io/println "empty")
[h | t] ~> (do (io/debug h)
(iterate t))))
List patterns also support fixed-length and mixed forms:
(let describe {list}
(match list
[x] ~> "singleton"
[a b | rest] ~> "at least two"
[] ~> "empty"))
[x] is equivalent to [x | []], and [a b | rest] is equivalent to [a | [b | rest]].
For patterns with multiple cases on one branch you can use |
(let is_weekend {day}
(match day
"Saturday" | "Sunday" ~> True
_ ~> False))
Match arms can also use a guard with if:
(let classify_positive {value}
(match value
(Some x) if (> x 0) ~> "positive"
(Some _) ~> "non-positive"
None ~> "missing"))
You can also destructure records in match arms using named fields:
(type Person
[(:name ~ String)
(:age ~ Int)])
(let age_of {person}
(match person
(Person :age age) ~> age))
Record patterns can be partial (you do not need to list every field), and they can be nested:
(type Address
[(:city ~ String)])
(type Person
[(:name ~ String)
(:address ~ Address)])
(let city_of {person}
(match person
(Person :address (Address :city city)) ~> city))
The list example above also introduces the do keyword. In some places, the compiler is expecting only one expression but you might like to do more. This gives us an opportunity to demonstrate something else about Mond: the friendly compiler errors. You may be tempted not to use do. You could write the example above as:
(let iterate {list}
(match list
[] ~> (io/println "empty")
[h | t] ~> (
(io/debug h)
(iterate t))))
But if you try to compile this, the compiler would say:
error: type mismatch: expected `Unit`, found `('a -> 'b)`
┌─ main.mond:6:85
│
6 │ (let iterate {list} (match list [] ~> (io/println "empty") [h | t] ~> ((io/debug h) (iterate t))))
│ ^^^^^^^^^^^ this argument has type `'a`
│
= expected `Unit`, found `('a -> 'b)`
= hint: `Unit` is not a function — if you meant to sequence multiple expressions, use `(do expr1 expr2 ...)`
Currying
Like many functional languages Mond supports currying.
For example we can define a function that adds two numbers
(let add_two {x y} (+ x y))
We can reuse that function via partial application to create a new function that adds 10 to a number
(let add_ten {x} (add_two 10 x))
Pipe
To chain multiple function calls and pipe data through Mond has |>.
(use std/io)
(let add_two {x y} (+ x y))
(let main {}
(let [x (|> 10
(add_two 1)
(add_two 1)
(add_two 1)
(add_two 1)
(add_two 1))]
(io/debug x)))
Running this will print
15
Placeholder Pipe
By default, each pipe step receives the current value as its single argument:
(|> x f g)
This is equivalent to:
(g (f x))
If you need to place the piped value somewhere else in a step, use _ as a
placeholder:
(|> 3
(add 1 _)
(mul _ 2))
This is equivalent to:
(mul (add 1 3) 2)
Rules:
- If a step has no
_,|>keeps normal behavior and passes the value as a single argument. - If a step has exactly one
_, the piped value is inserted at_. - If a step has more than one
_, compilation fails.
The Standard Library
Mond’s standard library is just like any other dependency, it is specified in your bahn.toml and can be pinned to a specific version.
The standard library is intended to be small but provide the essential building blocks for writing mond.
Imports
To get started with the standard library, we first need to introduce a new concept: imports. Mond defines the keyword use. Like everything in Mond, this lives inside an S-expression.
(use std/io) at the top of the file brings the module io from std into scope.
Here’s an example:
(use std/io)
(let main {}
(io/println "hello")
(io/println (string/to_upper "hello")))
All of io’s functions and type defs are in-scope using the io/ qualifier.
If you only want to bring in a subset of what’s defined in a module and use it in an unqualified manner, you can use square brackets to do so.
(use std/io [println])
(let main {}
(println "hello"))
You can also import everything unqualified with [*]:
(use std/io [*])
Monadic Types
The standard library also provides some useful types like Option and Result. It is idiomatic to import these in an unqualified manner. This also imports these types’ constructors (i.e. None, Some, Ok and Error).
(use std/result [Result])
(use std/option [Option])
The language also provides syntactic sugar with let?. It chains operations that return a Result, short-circuiting on the first error. let? is built in, so no bind import is required.
(use std/result [Result])
(use std/io)
(let might_fail {} (Ok 10))
(let might_also_fail {x} (Ok (+ x 10)))
(let main {}
(let? [a (might_fail) b (might_also_fail a)]
(do (io/debug a)
(io/debug b)
(Ok (+ a b)))))
This desugars to:
(match (might_fail) (Ok a) ~> (match (might_also_fail a) (Ok b) ~> (Ok (+ a b)) (Error e) ~> (Error e)) (Error e) ~> (Error e))
If you run it, you’ll see:
10
20
Processes
Because Mond targets the BEAM, it can use Erlang processes directly via std/process.
(use std/process)
Core process primitives:
process/spawnandprocess/spawn_linkto start work concurrentlyprocess/new_subjectto create a typed mailbox endpointprocess/sendandprocess/receive_timeoutto exchange typed messagesprocess/new_name,process/register, andprocess/named_subjectfor globally named mailboxes
Minimal subject round-trip:
(use std/process)
(use std/testing [assert_eq])
(test
"subject send/receive"
(let [subject (process/new_subject)
_ (process/spawn
(f {_} ->
(do (process/sleep 10)
(process/send subject "pong")
())))]
(assert_eq (process/receive_timeout subject 1000) (Ok "pong"))))
Named subjects:
(use std/process)
(use std/result [Result])
(use std/testing [assert_eq])
(test
"named subject"
(let [name (process/new_name "named-mailbox")
subject (process/named_subject name)]
(let? [_ (assert_eq (process/register name) (Ok ()))]
(let? [_ (assert_eq (process/send subject "named-pong") "named-pong")]
(assert_eq (process/receive_timeout subject 1000) (Ok "named-pong"))))))
receive_timeout returns Result 'm Unit:
Ok messagewhen a message with the right subject tag arrivesError ()on timeout
Unknown
std/unknown is for decoding values from untyped boundaries (FFI, external data, dynamic payloads) into typed Mond values.
(use std/unknown [DecodeError])
The core flow is:
- Start with an
Unknownvalue (unknown/fromorunknown/from_string) - Build a decoder (
unknown/int,unknown/string,unknown/list,unknown/field, …) - Run it with
unknown/run
Simple decode:
(use std/testing [assert_eq])
(test
"unknown/int success"
(assert_eq (unknown/run (unknown/from 42) (unknown/int)) (Ok 42)))
Failure includes structured errors:
(use std/testing [assert_eq])
(test
"unknown/int failure"
(assert_eq
(unknown/run (unknown/from "nope") (unknown/int))
(Error [(DecodeError :expected "Int" :found "String")])))
Composed decoders:
(use std/map)
(use std/testing [assert_eq])
(let player_data {}
(map/put "name" "Lucy" (map/new)))
(test
"unknown/field success"
(assert_eq
(unknown/run
(unknown/from (player_data))
(unknown/field "name" (unknown/string)))
(Ok "Lucy")))
unknown/run returns Result 'a (List DecodeError), so decoding can be handled with normal Result control flow (let?, bind, match).
Testing
test is a language keyword. You do not import it.
Testing helpers are regular std functions and must be imported explicitly.
(use std/string)
(use std/result [Result])
(use std/testing [assert_eq])
(test "string/length"
(let? [_ (assert_eq (string/length "hello") 5)]
(assert_eq (string/length "") 0)))
Notes:
testdeclarations are only allowed in files undertests/let?short-circuits onResult, nobindimport required(use std)does not importassert_eqinto unqualified scope
Erlang FFI
One of the big benefits of targeting the BEAM is being able to leverage a rich and mature ecosystem.
We can do so by binding Erlang functions to Mond names with extern let. We said earlier that Mond does not use type signatures, this was a little white lie. extern declarations are the only place where Mond uses type signatures.
(extern let system-time ~ (Unit -> Int) erlang/system_time)
pub extern let makes the binding importable by other modules — this is how large parts of the standard library are implemented e.g.
(pub extern let println ~ (String -> Unit) io/format)
We can do something similar for opaque foreign-backed types.
(pub extern type Pid)
(pub extern type ['k 'v] Map maps/map)
The trailing module/type target on extern type is optional metadata. Use it
when it helps document the foreign runtime type, but plain opaque declarations
such as (pub extern type Pid) are also valid.
Roadmap
Phase 1
The aim of Phase 1 is to have a stable and usable language. At completion the core language should be bug free for all toy and small programs.
- language spec
- compiler pipeline
- basic tree sitter grammar
- compilation and runtime bug free for toy and sample programs
- mvp standard library
Phase 2
The aim of Phase 2 is to make Mond more usable, primarily through completing the standard library, adding package management to the CLI and a basic lsp.
- complete standard library
- mvp package management
- http client lib
- basic lsp (goto def, rename, errors)
- increase test coverage across entire compiler pipeline
Phase 3
The aim of Phase 3 is to make mond even more usable, primarily through further integration with OTP.
- OTP / erlang libraries (e.g. gen server support, process support)