Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 x squared as Mond uses 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 pi and r_sq using a local let binding
  • 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 let bindings, by convention, use snake_case identifiers
  • this function has the type Float -> Float which Mond infers 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:

  1. the name of the type (Option in the case above)
  2. the constructors (None and Some)

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 with match, 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/spawn and process/spawn_link to start work concurrently
  • process/new_subject to create a typed mailbox endpoint
  • process/send and process/receive_timeout to exchange typed messages
  • process/new_name, process/register, and process/named_subject for 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 message when a message with the right subject tag arrives
  • Error () 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:

  1. Start with an Unknown value (unknown/from or unknown/from_string)
  2. Build a decoder (unknown/int, unknown/string, unknown/list, unknown/field, …)
  3. 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:

  • test declarations are only allowed in files under tests/
  • let? short-circuits on Result, no bind import required
  • (use std) does not import assert_eq into 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)