Language Tour

A fast walkthrough of March syntax and core concepts. Every snippet here runs as-is.


Comments

-- this is a line comment

{- this is a block comment
   {- they nest -}
-}

Modules

Every March file begins with exactly one module declaration:

mod MyApp do
  -- everything goes here
end

Modules can be dotted (for multi-file projects):

mod MyApp.Router do
  -- ...
end

Modules can be nested:

mod Outer do
  mod Inner do
    fn greet() do println("from Inner") end
  end
end

Variables

let binds a name in the current block. No in is needed — subsequent expressions in the block see the binding:

fn main() do
  let x = 42
  let y = x + 1
  println(int_to_string(y))   -- 43
end

Type annotations are optional:

let count : Int = 0
let name : String = "March"

Module-level let defines constants:

mod Config do
  let max_retries = 3
  let base_url = "https://example.com"
end

Primitive Types

Type Examples Notes
Int 42, -7, 0 64-bit signed integer
Float 3.14, -0.5 64-bit floating-point
Bool true, false  
String "hello" UTF-8
Char (via String) Unicode scalar
() () Unit (no value)

Float arithmetic uses dotted operators to make it explicit:

let sum = 1.0 +. 2.5    -- Float +
let diff = 3.0 -. 1.5   -- Float -
let prod = 2.0 *. 4.0   -- Float *
let quot = 9.0 /. 3.0   -- Float /

Integer arithmetic uses the plain operators: +, -, *, /, %.


Strings

Concatenate with ++:

let greeting = "Hello, " ++ "World!"

String interpolation with ${}:

let name = "Alice"
let msg = "Hello, ${name}!"   -- "Hello, Alice!"

Triple-quoted strings preserve newlines:

let html = """
  <div>
    <p>Hello</p>
  </div>
"""

Useful builtins: int_to_string, float_to_string, bool_to_string, to_string.


Functions

Named functions use fn name(params) do ... end:

fn add(x : Int, y : Int) : Int do
  x + y
end

Return type annotation is optional:

fn square(n) do
  n * n
end

Functions can have multiple expressions in the body; the last one is the return value:

fn summarize(xs : List(Int)) : String do
  let n   = List.length(xs)
  let sum = List.fold(xs, 0, fn acc x -> acc + x)
  "count=" ++ int_to_string(n) ++ " sum=" ++ int_to_string(sum)
end

Private Functions

Use pfn to make a function private to its module:

mod Passwords do
  pfn hash_raw(s : String) : String do
    -- not callable outside this module
    Crypto.sha256(s)
  end

  fn verify(plain, stored) do
    hash_raw(plain) == stored
  end
end

Default Arguments

fn greet(name, greeting \\ "Hello") do
  greeting ++ ", " ++ name ++ "!"
end

greet("World")          -- "Hello, World!"
greet("World", "Hi")    -- "Hi, World!"

All defaulted parameters must be trailing.

Multi-Head Functions

Consecutive clauses with the same name are merged into a single function with pattern dispatch (Elixir-style):

fn len(Nil) do 0 end
fn len(Cons(_, t)) do 1 + len(t) end
fn abs(n) when n < 0 do -n end
fn abs(n) do n end

Lambdas

Lambdas use arrow syntax: fn params -> body.

fn x -> x + 1                -- single param
fn (a, b) -> a + b           -- multiple params (parenthesized)
fn () -> 42                  -- ZERO-ARG: must use fn () -> ...
fn _ -> "ignored"            -- wildcard (1-arg, discards the value)

Multi-expression lambda bodies use let bindings:

fn x ->
  let y = x + 1
  let z = y * 2
  z

Common mistake: fn -> expr is a parse error. Zero-arg lambdas require fn () -> expr.


If / Else

if x > 0 do
  "positive"
else
  "non-positive"
end

else is optional (returns () if omitted):

if debug_mode do
  println("debug info")
end

Both branches can contain multiple expressions:

if List.is_empty(xs) do
  let msg = "list is empty"
  println(msg)
else
  let first = head(xs)
  println("first: " ++ to_string(first))
end

There is no then keyword in March.


Match

Pattern matching is the primary control flow construct:

match xs do
  Nil        -> "empty"
  Cons(h, _) -> "starts with " ++ to_string(h)
end

Arms are separated by newlines (or |). Multi-expression arms:

match result do
  Ok(v) ->
    let s = to_string(v)
    println("success: " ++ s)
  Err(e) ->
    println("error: " ++ e)
end

Guards with when:

match n do
  x when x > 100 -> "big"
  x when x > 0   -> "small"
  _               -> "non-positive"
end

Pattern-free multi-way conditional (cond):

match do
  score >= 90 -> "A"
  score >= 80 -> "B"
  score >= 70 -> "C"
  _           -> "F"
end

With Expressions

with chains Result/Option bindings — short-circuits on failure:

with Ok(user) <- fetch_user(id),
     Ok(data) <- fetch_data(user.token) do
  process(user, data)
else
  Err(e) -> handle_error(e)
end

Each pat <- expr: if expr matches pat, continue; otherwise fall through to else (or propagate the non-matching value).


Pipe Operator

|> threads the left value as the first argument of the right expression:

[1, 2, 3, 4, 5]
|> List.filter(fn x -> x % 2 == 0)
|> List.map(fn x -> x * x)
|> List.fold(0, fn acc x -> acc + x)

Equivalent to:

List.fold(
  List.map(
    List.filter([1, 2, 3, 4, 5], fn x -> x % 2 == 0),
    fn x -> x * x),
  0,
  fn acc x -> acc + x)

The pipe version reads left-to-right and matches the mental model.


Tuples

let pair  = (1, "hello")        -- (Int, String)
let triple = (1, 2.0, true)     -- (Int, Float, Bool)
let unit  = ()                  -- ()

Destructure in patterns:

let (a, b) = pair

Lists

let empty = []
let nums  = [1, 2, 3]           -- sugar for Cons(1, Cons(2, Cons(3, Nil)))
let more  = Cons(0, nums)       -- [0, 1, 2, 3]

List comprehensions:

[x * 2 for x in [1, 2, 3]]               -- [2, 4, 6]
[x for x in nums, x % 2 == 0]            -- even numbers only
[to_string(x) for x in [1, 2, 3]]        -- ["1", "2", "3"]

Records

Define a record type:

type Point = { x : Float, y : Float }
type User  = { name : String, age : Int, admin : Bool }

Create a record:

let p = { x = 1.0, y = 2.0 }
let u = { name = "Alice", age = 30, admin = false }

Access fields:

p.x         -- 1.0
u.name      -- "Alice"

Functional update (returns a new record with some fields changed):

let p2 = { p with x = 5.0 }
let u2 = { u with age = 31, admin = true }

Typed Holes

A ? anywhere in an expression is a typed hole. The compiler reports what type it expects:

fn mystery(xs : List(Int)) : Int do
  List.fold(xs, ?, fn acc x -> acc + x)
  -- error: hole of type Int
end

Named holes ?name for documentation:

fn process(x) do
  ?todo_implement_this
end

Block Expressions

do ... end is an expression that evaluates its body and returns the last value:

let result = do
  let a = compute_a()
  let b = compute_b(a)
  a + b
end

Docstrings

Attach documentation to any definition:

doc "Returns the absolute value of n."
fn abs(n : Int) : Int do
  if n < 0 do -n else n end
end

Multi-line docs:

doc """
Splits a string by the given delimiter.

Returns a list of substrings. If the delimiter does not
appear, returns a list containing the original string.
"""
fn split(s : String, delim : String) : List(String) do
  -- ...
end

Testing

March has built-in test syntax:

test "addition is commutative" do
  assert (1 + 2 == 2 + 1)
end

describe "list operations" do
  test "map preserves length" do
    let xs = [1, 2, 3]
    assert (List.length(List.map(xs, fn x -> x * 2)) == 3)
  end

  test "filter reduces length" do
    let xs = [1, 2, 3, 4, 5]
    assert (List.length(List.filter(xs, fn x -> x > 3)) == 2)
  end
end

Run with:

dune exec forge -- test

Next Steps