mimas

mimas

mimas is a statically typed, embeddable scripting language for Rust. It carries over much of Rust’s syntax and ergonomics, reshaping the rest to deliver what a scripting layer is good for: fast iteration, quick compile times, and runtime flexibility.

Typed

Static typing with inference, user-defined types, exhaustive pattern matching, and more of the same features that empower you in Rust.

let area: float? = match my_shape {
    Shape::Circle(r) => r * r * std::math::PI,
    Shape::Rectangle(w, h) => w * h,
    _ => null,
};

Flexible

Rigorous, not rigid. Flexibility shouldn’t cost you safety. Inference writes your types, compiles stay fast, and errors point you toward the fix instead of just turning you away — so writing it stays quick, iterative, and intuitive.

let nums = [5, 3, 8, 1];
let big = for n in nums { if n > 4 collect n; };
print(f"found {big.len()}: {big}"); // -> found 2: [5, 8]

Extendable

Share your Rust types and functions with the mimas macro, all while maintaining type safety. The macro alone is all you need for mimas to find it.

// rust
#[mimas]
struct User(String);
impl User {
    fn greet(self) {
        println!("Hello, {}!", self.0);
    }
}
// mimas
let user = User("mimas");
user.greet(); // Hello, mimas!

Robust

  • Don’t Panic — mimas treats any panic, compiler or VM, as a bug.
  • Tested top to bottom — over 1,000 tests cover every corner of the codebase. Even the tests are tested, thanks to cargo mutants.
  • Helpful diagnostics — bugs are caught at their source with clear, illustrative reports powered by miette:
  error:  non-exhaustive match
   ╭─[tools/src/main.mim:3:1]
 23 match Color::random() {
 4       Color::Red => print("Red!"),
 5       Color::Blue => print("Blue!"),
 6 }
   · ─── missing pattern `Color::Green`
 7 │     
   ╰────
  ╰─▶   advice:  `Color::Green` defined here
         ╭─[tools/src/color.mim:6:5]
       5 │     Blue,
       6 │     Green,
         ·     ──┬──
         ·       ╰── this variant has no matching arm
       7 │ }
         ╰────

mimas Today

mimas is in its infancy – 0.1.0 marks it reaching its first milestone, where the core features of the language have been fully implemented, but there’s plenty more to do.

Warning

mimas is ready for your exploration and hobby work, but should not yet be depended upon for consequential projects.

mimas will grow and adapt per its needs, which may include many breaking changes between versions!

Please file any bugs or requests on our issues page.

Future Goals

While there are many opportunities for growth in mimas, a few are considered critical to its identity.

Speed

mimas wants to be a scripting layer where you don’t have to sacrifice saftey. In the same vein, we should not make you sacrifice speed. While it will never seek to come close to Rust itself, or JIT alternatives, diligent work to its VM and compiler should place it among the faster bytecode VMs.

See rough current [benchmarks] for insight onto where we’re at today.

Core Pacts

While mimas already supports pacts (its analogue of traits), much of its core behavior ought to be extendable via built-in pacts you can implement upon your own types, like Iterator, Error, and the various other operations such as Add and Unwrap.

LSP

While mimas does have a TextMate grammar it does not currently have a language server. This is needed to match our goals within UX and ease.

Why mimas?

mimas is a side project, and a young one. It isn’t trying to win you away from a language you already trust – those have communities, libraries, and years of polish mimas can’t pretend to match. This page is instead about why I keep building it, and the north star I steer by while I do. It centers around a trade I’m tired of making.

The trade

Plenty of projects end up wanting two languages. You build the serious part – the engine, the core, the thing that has to be fast and correct – in something strict and heavy. Then, when you want to move, to describe behavior, try an idea, change something and see it a second later, you reach for a scripting layer on top.

That second layer is where the trade lives. Scripting layers tend to commit hard to flexibility at the expense of saftey. They’re forgiving right up until they aren’t, when you slam into a runtime error from a trivial mistake.

This has been a constant headache throughout my career in game development, and one with real consequences; games have enormous performance demands while also needing to be remarkably robust. There isn’t much room to recover from an error mid-gameplay. You can wrap the unstable parts and pray, but catching an error you didn’t model is the same as throwing it out and hoping no broken state leaked to the surface as something far harder to trace. In the end, it feels as though I’m spending more time untangling cryptic crashes than I am actually making games.

mimas is my detox from those frusterations: a fun, snappy programming experience without gnashing my teeth at a crash from a typo the compiler absolutely should have been capable of warning me about.

Coach, not cop

The only path towards such a language is a series of calculated compromises. We’re not going to produce some magical language that lets us program with our eyes closed yet still fulfill a borrow checker – instead, each part of the language needs to weigh the ergonomics and the saftey. Will this frustrate me when I’m just trying to write something quick? Will this come back to haunt me at runtime? Each choice swings a little bit in one direction or the other, our ethos is to try to stay upright on that tightrope.

Ultimately, that has turned out to look a lot more like Rust than Python, just with certain pieces removed. Some are to increase flexibility (garbage collection instead of a borrow checker, no immutability beyond const decalartions). Others are to reduce complexity, both for the compiler and the programmer (no references or pointers, no lifetimes to annotate). We start from safety and walk only as far away as we must to reach the flexibility we expect, and to keep recompiling in the blink of an eye.

In 0.1.0 the ergonomics come first, and speed is a longer project. mimas is already in the right ballpark in its benchmarks, but there’s plenty of headroom left to chase as it grows.

A small, clear target

None of this is a new wish. Wanting expressiveness, safety, and a fast loop all at once is a familiar itch, and people wiser than me have spent whole careers reaching for pieces of it (Mun, in particular, has made terrific headway in this effort). That said, sitting with a question others have sat with doesn’t make it any less fun, and that’s reason enough.

mimas is named after one of Saturn’s smallest moons – which is, in turn, named after a giant from Greek mythology. If a little language tacking a big problem sounds like fun to you too, I’d be thrilled to have your stubborn thoughts and contributions along the way.

Quick Reference

This page covers the entire language with brief examples for those who want a quick tour. Links to the full reference are left at each section.

More examples can be found on the examples page.

Variables

See Variables & Constants.

let x = 0;           // inferred as int
let y: float = 1.5;  // or annotate the type explicitly
x = 10;              // `let` bindings are reassignable
const MAX = 100;     // `const` is not, and must be compile-time known

let x = "a str";     // shadowing: reuse a name, even with a new type

There is no mut and no borrow checker — a managed runtime handles memory, so the binding model is just let (reassignable) and const (not).

Primitive types

See Primitive Types.

let i: int   = 1_000; // 64-bit signed; underscores ignored; 0xff hex too
let f: float = 3.14;  // 64-bit IEEE-754
let b: bool  = true;
let s: str   = "hi";  // no char type; a single character is a 1-char str

// int + float promotes to float; there is no `as` cast — use methods
let g: float = i.to_float();
let back: int = g.to_int(); // truncates toward zero

Integer arithmetic is checked: overflow is a runtime error, never a silent wrap.

Operators

See Operators.

// arithmetic — / always yields float, ~/ truncates, % is remainder
7 + 2;   7 - 2;   7 * 2;   7 / 2;   7 ~/ 2;   7 % 2;
"ab" + "cd";  // -> "abcd"  (+ concatenates strings)

a < b;   a == b;   a != b;   // comparison -> bool (ordering: numbers only)
x && y;  x || y;  !x;        // logical, short-circuiting
a & b;   a | b;   a ^ b;   a << 1;   a >> 1; // bitwise (int)

a += 1;  a *= 2;  a ~/= 2;  a ??= z; // every binary op has a compound form

Strings

See Primitive Types.

let multi = "spans
multiple lines";  // the newline becomes part of the value

let name = "world";
let msg = f"hi {name}, 1+1={1 + 1}"; // f-string interpolates any expression
let lit = f"{{literal braces}}";     // -> "{literal braces}"

let clean = "  Hi  ".trim().to_lower(); // str methods chain

Collections

See Collections.

// Array — ordered, growable, single element type: [T]
let xs: [int] = [3, 1, 2];
xs.push(4);
let first = xs[0]; // 0-based; reading past the end faults at runtime

// Dictionary — hash map of str keys to one value type: ~{V}
// (the leading ~ tells it apart from a block)
let scores: ~{int} = ~{ alice = 10, bob = 7 }; // keys are bare idents
let a: int? = scores["alice"]; // indexing yields V? (missing key -> null)
scores.insert("cy", 3);

// Tuple — fixed-length, heterogeneous, indexed by position
let pair: (int, str) = (1, "one");
let one = pair.1;    // -> "one"
let (x, y) = (3, 4); // destructuring let

// `in` tests membership
0 in xs;            // array: contains the element
"alice" in scores;  // dict: has the key
"ell" in "hello";   // str: substring

Control flow

See Control Flow.

Everything here is an expressionif, match, blocks, and loops all yield values. Block braces are optional around a single expression.

if x == 0 {
    print("zero");
} else if x > 0 {
    print("positive");
} else {
    print("negative");
}

let sign = if x >= 0 1 else -1; // as an expression, braces optional
// an `if` with no `else` must yield () — its body can't produce a value

let total = {     // blocks yield their final semicolon-free expression
    let a = 3;
    a + 5         // -> 8
};
// match — first arm to fit wins; exhaustiveness is checked (Maranget-style).
// the arms below show the pattern kinds, not one real scrutinee.
let label = match value {
    0 => "zero",                  // literal
    1 | 2 | 3 => "small",         // or-pattern (multiple literals)
    n if n > 100 => "big",        // guard
    found? => found.name,         // null-bind: binds when value isn't null
    Point { x = 0, y } => f"{y}", // struct destructure (fields use `=`)
    Shape::Circle(r) => area(r),  // enum-variant destructure
    (a, b) => a + b,              // tuple destructure
    other => fallback(other),     // bare ident binds anything; `_` too
};

let code = match cmd {  // end with a bare `!` to promise exhaustiveness;
    "go" => 1,          // reaching it is a runtime fault
    "stop" => 2,
    !
};
loop {                 // infinite until `break`
    if done() { break; }
    continue;
}
let answer = loop { break 42; }; // `loop` can `break value` -> 42

while ready() { work(); }
for item in xs { print(item); }  // iterates arrays, dicts, str, 0..n on int

// `collect` turns any loop into an array builder — mimas's comprehension
let doubled = for n in xs collect n * 2;
let evens   = for n in xs { if n % 2 == 0 collect n; }; // filter with `if`

Options

See Options.

A value that may be null is an option, written T?. null exists only where a ? invites it, so the compiler guarantees you never hit an unexpected null.

let maybe: int? = lookup(); // an int, or null
// let bad = maybe + 1;     // compile error: maybe may be null

let n = maybe ?? 0;     // ??  fallback when null
let len = name?.len();  // ?.  short-circuits to null on a null receiver
let cell = grid[1]?[0]; // ?[] the same, for indexing
let sure = maybe!;      // !   asserts non-null (faults if it was null)

if let n = maybe {      // bind the unwrapped value when present
    handle(n);
} else {
    fallback();
}

let n? = maybe else { return; }; // let/else: bind, or diverge and move on

T?? automatically flattens to T? — there is no option-of-an-option.

Results & errors

See Results & Error Handling.

Two tiers of failure: an unrecoverable panic that halts the VM, and a recoverable result (T!) a caller can handle.

panic("unreachable"); // halts the VM immediately
todo("later");        // panic's cousin for unfinished code (msg optional)

fn parse_port(s: str) -> int! { // `!` return type lets the fn `raise`
    let n = s.to_int();         // str.to_int() -> int?
    if n == null {
        raise "not a number";   // the raised value must be a str*
    }
    n!                          // a bare T auto-wraps as the Ok case
}

// unwrap with `!`: take the value, or fault hard if it raised
let p: int = parse_port("80")!;

// recover with `absolve`: handle the error string, keep running
let q: int = parse_port(input) absolve |e| {
    print(f"bad: {e}");
    8080
};

panic, todo, return, raise, and an endless loop {} are all never (!) typed, so they slot into any branch without disturbing its type.

*typed errors are coming

In 0.1.0 the error carried by a result is always a str. The plan is to move to an Error pact you can implement for your own types, so failures can carry structured data. For now, a descriptive message is the tool.

Functions & closures

See Functions & Closures.

fn square(n: int) -> int { n * n }   // body is a block; last expr returns
fn greet(name: str) { print(name); } // no `->`: returns ()

// optional params (with const-foldable defaults) follow the required ones
fn connect(host: str, port=8080) {}
connect("localhost");            // port defaults to 8080
connect("localhost", port=9090); // name an optional to skip earlier ones

// closures: inline, anonymous, pipe-delimited; return type inferred
let add = |a: int, b: int| a + b;
let now = || current_time();     // no args

fns live only at the file’s top level and capture nothing — they’re second-class (passable as arguments, but not bindable). Closures are first-class (bindable, storable) and capture their surrounding scope.

let g = add;       // ok — closures are values
// let h = square; // error — a fn isn't a value you can bind

Structs

See Structs.

struct Player { name: str, score: int }

let p = Player { name = "ada", score = 0 }; // construct with `=`
print(p.name);                              // read fields with `.`

impl Player {                  // a type may have many impl blocks
    const MAX = 100;           // associated const
    fn new(name: str) -> Self {              // associated fn (no self)
        Self { name = name, score = 0 }
    }
    fn won(self) -> bool {                   // method (takes self)
        self.score >= Self::MAX
    }
}

let p = Player::new("ada"); // `::` reaches associated items
p.won();                    // `.` calls a method (= Player::won(p))

struct Vec2(float, float)   // tuple struct: positional fields, indexed
let v = Vec2(1.0, 2.0);
let vx = v.0;
struct Marker;              // field-free marker type

Enums

See Enums.

enum Shape {
    Circle(float),         // tuple variant
    Rect(float, float),
    Labeled { text: str }, // struct variant
    Empty,                 // payload-free
}

let c = Shape::Circle(1.0);
let e = Shape::Empty;

impl Shape {
    fn area(self) -> float {
        match self {            // every variant must be covered
            Shape::Circle(r) => 3.14159 * r * r,
            Shape::Rect(w, h) => w * h,
            _ => 0.0,
        }
    }
}

Pacts

See Pacts.

A pact is mimas’s trait analogue: a named set of method, associated-function, and constant signatures a type satisfies with impl Pact for Type. It’s how you abstract over types without generics.

pact Draw {
    fn draw(self);
    fn describe() { print("a shape"); } // items may ship a default
}

struct Square;
impl Draw for Square {
    fn draw(self) {}                    // `describe` is inherited
}

fn render_all(items: [Draw]) { // a pact stands in anywhere a type is expected
    for item in items { item.draw(); }
}

struct Widget {
    item: Named + Identified, // require several pacts at once with `+`
}

Modules & scripts

See Modules and Scripts.

A plain file is a script — it runs top to bottom, no main required. A file that opens with module is a library: it organizes items and can’t run top-level code.

// colors.mim
module @; // name the module after the file (or: `module graphics::colors;`)

const INTERNAL = 0;        // private by default
pub const RED = "#ff0000"; // `pub` exposes it across modules
pub fn mix(a: str, b: str) -> str { a }
// main.mim — a script
use colors;               // reach items via colors::RED
use colors::{ RED, mix }; // or pull names in directly (also `colors::*`)

print(mix(RED, "#00ff00"));

Extending with Rust

See Extension with Rust.

mimas is built to embed: share Rust types and functions with the #[mimas] macro and they’re type-checked like native ones.

#[mimas]
struct User(String);

#[mimas]
impl User {
    fn greet(self) { println!("Hello, {}!", self.0); }
}
let user = User("mimas");
user.greet(); // -> Hello, mimas!

Benchmarks

testmimas v0.0.0Python v3.14Rhai v1.25.1Rune v0.14.2Piccolo (Lua) v0.3.3
fibonacci0.58s0.21s1.86s0.61s0.47s
1mil loop0.08s0.06s0.09s0.08s0.03s
prime numbers0.51s0.39s1.06s0.73s0.25s
particles0.79s0.55s2.93s1.00s1.07s
shapes0.62s0.42s1.70s0.67s0.55s
  • Measured on a 2021 MacBook Pro M1 Max

What these measure

The first three are arithmetic, recursion, and tight loops — work that rewards a mature, heavily tuned interpreter (Python’s specializing interpreter especially).

The last two exercise mimas’s data abstractions:

  • particles — read and write struct fields in a hot loop (the cost of a record).
  • shapes — build a different enum variant each iteration and dispatch on it with match (the cost of a tagged variant).

Note

A note on fairness:

Each language models a record/variant with its own idiomatic construct. Rhai and Lua (Piccolo) have neither structs nor enums at the script level, so their particles and shapes rows use hash maps (Rhai object maps, Lua tables) — the only named-field option they offer. Those rows therefore measure the cost of the abstraction, not raw arithmetic throughput: read them as “what does modelling your data this way cost?”, not “which language computes faster”.

Caveats

mimas has had essentially no runtime optimization yet — no instruction specialization, and ADT construction allocates more than it needs to (each shapes iteration currently makes a few small allocations that planned work will remove). Expect these numbers to move.

Compile speed

Where the runtime is still young, the compiler is quick — for scripts there’s effectively no compile step you’d notice. The whole pipeline (parse, type-check, lower, and emit bytecode, with no execution) runs at roughly 400,000 lines per second.

programlinescompile
a small module~100< 1 ms
a project~10,000~25 ms
a large project~100,000~0.24 s

Measured with mimas build on the same M1 Max, over a representative corpus (consts, structs, enums, functions, deeply-nested types) generated by the fodder project (tools/fodder). Like the runtime numbers, expect these to move.

Reproduce

  • tools/benchmarks/compare.sh — cross-language wall-clock comparison (hyperfine).
  • tools/benchmarks/compile-bench.sh — compile throughput over generated source (mimas build).
  • cargo bench -p mimas — in-process VM and compile microbenchmarks, with per-run allocation counts (Divan).

Language Reference

This is the complete reference for the mimas language, version 0.1.0. It describes the syntax and semantics of the language itself — the parts you write in a .mim file. Extending mimas from Rust is covered separately in Extension with Rust.

mimas is a statically typed, embeddable scripting language for Rust. It borrows much of Rust’s syntax and ergonomics and reshapes the rest to deliver what a scripting layer is good for: fast iteration, quick compile times, and runtime flexibility.

enum Shape {
    Circle(float),
    Rect(float, float),
}

fn area(s: Shape) -> float {
    match s {
        Shape::Circle(r) => 3.14159 * r * r,
        Shape::Rect(w, h) => w * h,
    }
}

let shapes = [Shape::Circle(2.0), Shape::Rect(3.0, 4.0)];
let total = for s in shapes collect area(s);
print(f"areas: {total}"); // areas: [12.56636, 12]

How to read this reference

Each page introduces one concept with short, runnable examples. Every snippet here is real mimas; you can drop one into a file and run it:

mimas example.mim

A few conventions used throughout:

  • We annotate types liberally (let a: int = 0) to make each example’s behavior explicit. In practice mimas infers most of them — you only need annotations on function signatures and type declarations. See Variables.
  • A // -> comment shows what an expression evaluates to or what print outputs.
  • A // compile error: … comment marks code that is intentionally rejected, with the reason the compiler gives.

A living document

mimas is pre-1.0 and under active development. This reference describes the language as designed for 0.1.0; a handful of described features are still being wired up under the hood. Where it matters, the page will say so.

Variables & Constants

Local variables are introduced with let. A let binding can be reassigned.

let a: int = 0;
let b = 1;  // type inferred as int

a = 10;     // reassignment is fine

Inference does the heavy lifting

Type inference in mimas is strong. The only places you must write a type are function signatures and type declarations (struct, enum, pact). Everywhere else the compiler works it out from the value.

We still annotate freely throughout this book — let a: int = 0 — purely to make each example’s types obvious. You won’t need most of them in real code.

Reassignment

A let binding is reassignable, but its type is fixed at declaration. Assigning a value of a different type is an error — use shadowing (below) if you genuinely want a new type.

let count = 0;
count = 5;        // valid
count += 1;       // valid, count is now 6
count = "six";    // compile error: expected int, found str

Shadowing

A new let may reuse a name already in scope. The new binding shadows the old one and can even change the type. This is a fresh variable, not a mutation of the original.

let a: int = 0;
let a: str = "hello!"; // valid — `a` is now a str

Let else

let/else binds a pattern and runs an else block when the value doesn’t match. The else has to diverge — return, break, panic, and so on — so execution only continues past it when the binding succeeded.

let Shape::Circle(r) = shape else {
    return;
};
// `r` is in scope from here on

It accepts any pattern match does, including the ? null-bind for options:

let port? = lookup_port() else {
    panic("no port configured");
};

Unused bindings

Prefixing a name with _ marks it as intentionally unused.

let _scratch = compute();

No warnings yet

0.1.0 only reports errors, not warnings, so the _ prefix is a no-op for now — it documents intent for when unused-variable warnings land.

Constants

Constants are declared with const and cannot be reassigned.

const FOO: int = 0;
FOO = 1; // compile error: constants cannot be mutated after declaration

A constant’s value must be computable at compile time. Literals, operators, and references to other constants are all fair game; anything that requires running code at runtime — like a function call — is not.

const BAR = 0;
const FIZZ = BAR + 1;          // valid — built from another const
const GREETING = "hello";      // valid — a literal

const NOPE = some_call();      // compile error: constants must be known at compile time

Todo

Our constant folding could likely handle evaluating whether a function is fully knowable at compile time, but that will come after 0.1.0.

Primitive Types

mimas has four primitive types: int, float, bool, and str.

Integers

int is a 64-bit signed integer. Integer literals can be written in decimal or hexadecimal, and _ may be used anywhere as a visual separator (it is ignored).

let a: int = 0;
let b: int = 0xff;          // 255, in hex
let c: int = 1_000_000;     // underscores are just for readability

Integer arithmetic is checked: an operation that overflows the 64-bit range is a runtime error rather than silently wrapping around.

let big = 9223372036854775807; // i64::MAX
let oops = big + 1;            // runtime error: integer arithmetic overflowed

Floats

float is a 64-bit IEEE-754 floating-point number.

let a: float = 0.0;
let b: float = 1_000.000_1;

An int and a float can be combined in one expression, but the result is always a float — the int is promoted. There is no implicit float -> int.

let a: float = 1 + 0.1; // valid — (int + float) => float
let b: int = 1 + 0.1;   // compile error: expected int, found float

Division always yields a float

The / operator always produces a float, even between two ints. When you want integer (truncating) division, use the ~/ operator.

let a: float = 7 / 2;  // 3.5
let b: int   = 7 ~/ 2; // 3

To convert deliberately between the two, use the conversion methods rather than a cast — mimas has no as.

let n: int = 42;
let f: float = n.to_float(); // 42.0
let back: int = f.to_int();  // 42 (truncates toward zero)

Booleans

bool is true or false. Comparisons and logical operators produce bools, and conditions in if, while, and friends must be bool.

let a: bool = true;
let b = 3 > 2;        // true
let c = a && !b;      // false

See Operators for the full set of comparison and logical operators.

Strings

Text is always str. mimas has no separate character type — a single character is just a one-character str. Strings use double quotes only ("), never single (').

let a: str = "hello!";
let b: str = "x"; // a one-character str, not a `char`

Any string literal may span multiple lines; the newline becomes part of the value.

let poem = "this string has
multiple lines!"; // -> "this string has\nmultiple lines!"

Interpolation (f-strings)

Prefix a literal with f to interpolate expressions inside { }. Any expression is allowed.

let name = "world";
let greeting = f"hello, {name}! 1 + 1 = {1 + 1}"; // -> "hello, world! 1 + 1 = 2"

To write a literal brace, double it:

let s = f"{{not interpolated}} but {1 + 1} is"; // -> "{not interpolated} but 2 is"

Strings come with batteries

str carries a generous set of built-in methods, and they chain naturally:

let cleaned = "  Hello, World  ".trim().to_lower(); // -> "hello, world"

Operators

mimas’s operators will look familiar coming from Rust or C-family languages. This page is the full list.

Arithmetic

NameSymbolTypesBehavior
Add+int, float, strAdds two numbers, or concatenates two strs.
Subtract-int, floatSubtracts.
Multiply*int, floatMultiplies.
Divide/int, floatDivides. Always yields a float.
Truncating divide~/int, floatDivides and truncates toward zero, keeping the operand type.
Modulo%int, floatRemainder after division (sign follows the left operand).
let a = 7 / 2;   // 3.5  (float)
let b = 7 ~/ 2;  // 3    (int)
let c = 7 % 2;   // 1
let d = "ab" + "cd"; // "abcd"

int + float promotes; / always floats

Mixing an int and a float promotes the result to float. And / produces a float even between two ints — use ~/ for integer division. Integer arithmetic is checked, so overflow is a runtime error, never a silent wrap.

Comparison

Comparisons produce a bool. Ordering (<, <=, >, >=) is defined for numbers only; str supports equality but not ordering.

NameSymbolTypes
Equal==any matching pair
Not equal!=any matching pair
Less / less-or-equal< <=int, float
Greater / greater-or-equal> >=int, float
print(2 < 3);          // true
print("hi" == "hi");   // true
print("a" < "b");      // compile error: these values cannot be compared like numerals

Logical

NameSymbolBehavior
And&&true if both operands are true. Short-circuits.
Or||true if either operand is true. Short-circuits.
Not! (prefix)Negates a bool.
let ok = is_ready() && !is_locked();

Bitwise

Bitwise operators work on int.

NameSymbolBehavior
And&Set each bit where both bits are set.
Or|Set each bit where either bit is set.
Xor^Set each bit where the bits differ.
Shift left<<Shift bits left.
Shift right>>Shift bits right.
print(6 & 3);  // 2
print(6 | 1);  // 7
print(6 ^ 3);  // 5
print(1 << 4); // 16

Null coalescing

The ?? operator supplies a fallback when its left operand is null. It is short-circuiting — the right side is only evaluated when needed — and it belongs to the option family of operators.

let name: str? = null;
let shown = name ?? "anonymous"; // -> "anonymous"

Assignment

Every arithmetic, bitwise, and coalescing operator has a compound-assignment form that updates a binding in place.

a += 1;
a -= 1;
a *= 2;
a /= 2;
a %= 3;
a ~/= 2;
a &= 1;
a |= 1;
a ^= 1;
a ??= fallback; // assign only if `a` is currently null

Blocks & Scope

Like Rust and Zig, a block in mimas is an expression. Curly braces group statements, and if the last thing inside — before the closing brace — is a bare expression (no trailing ;), the block evaluates to it.

let a = {};          // -> ()  (no trailing expression)
let b = { 0 };       // -> 0
let c = {
    let x = 1;
    let y = 2;
    x + y            // no semicolon: this is the block's value
};                   // -> 3

Because blocks are expressions, the same rule powers if, match, and loops — they can all produce values.

Scope

mimas is lexically scoped: a block defines a scope. Code can read and reassign variables from any block that encloses it, but not the other way around. When a block ends, the variables it declared fall out of scope.

let a = 0;
{
    let b = a; // valid — `a` is visible from the enclosing scope
}
let c = b;     // compile error: `b` is not defined out here

A let inside a block is a brand-new binding. It does not disturb a same-named variable in an outer scope — that’s shadowing. Reassignment (=, without let), on the other hand, reaches outward to the existing variable.

let a = 0;
let b = 0;
{
    a = 1;     // reassigns the outer `a`
    let b = 1; // new binding, shadows the outer `b` only inside this block
}
// -> a is 1, b is 0

The Unit & Never Types

Three things in mimas stand in for “no ordinary value here,” and they mean genuinely different things:

  • null — there could be a value, but right now there isn’t. It’s a real value you can hold and test, and it only appears behind an option (T?).
  • (), the unit type — truly nothing. The result of an expression that was never going to hand back a value.
  • !, the never type — a dead end. The “result” of an expression after which no further code can run.

null belongs to Optionals; this page covers the other two.

Unit — ()

Any expression that doesn’t produce a meaningful value evaluates to the unit type, written (). An empty block, a for loop, a function with no return — they all yield ().

let a: () = {};
let b: () = loop { break; };

Never — !

The never type, written !, is the type of an expression that diverges — one after which no code can run. It arises from return, panic, todo, and infinitely-running loop {}. Only the compiler can produce a !; you can never annotate a binding with it directly.

let a = loop {};       // `a` is `!` — the loop never ends, so `a` is never assigned
panic("oh no!");       // `panic` diverges, so it is `!`

What makes ! useful is that it coerces into any type. Because a diverging branch can never actually supply a value, the compiler lets it stand in for whatever type the surrounding code expects. That’s why one arm of an if can bail out while the other still determines the type:

let a: int = if some_condition {
    0
} else {
    return;  // `return` is `!`, so it fits where an `int` is expected
};

The same applies to match: an arm that panics contributes !, which folds into the common type, so it doesn’t force the other arms to become optional.

Control Flow

mimas’s control-flow constructs — if, match, and the loops — are all expressions: each one can evaluate to a value, not just steer execution. That single idea shows up everywhere in this section, so it’s worth keeping in mind:

let label = if score >= 50 { "pass" } else { "fail" };

let kind = match tag {
    0 => "circle",
    _ => "other",
};

let first_even = for n in numbers { if n % 2 == 0 { break n; } };

The pages here cover if & if let, match, the loop / while / for family, and the collect operator for building arrays out of a loop.

If & If Let

An if expression tests a bool condition and runs its block when the condition holds. An optional else — including chained else if — handles the other case.

if ready {
    launch();
}

if ready {
    launch();
} else if waiting {
    hold();
} else {
    abort();
}

As an expression

Because if is an expression, it can produce a value. When you use it that way, an else is required — without one, the if might produce nothing, so the only value it’s allowed to have is ().

let tier: int = if vip {
    0
} else {
    1
};

let c = if vip { 0 }; // compile error: an `if` used as a value must have an `else`

Each branch must agree on a type — though a diverging branch (one that returns, breaks, or panics) contributes the never type and bows out of that agreement:

let port: int = if configured {
    configured_port()
} else {
    panic("no port configured"); // `!` — doesn't fight the `int` from the other arm
};

If let

if let swaps the bool test for an option test. It evaluates an expression and, if the result is not null, binds the unwrapped value and runs the block. An else runs when the value was null.

if let port = lookup_port() {
    // runs only when `lookup_port()` was not null;
    // `port` is the non-null value in here
    connect(port);
} else {
    use_default();
}

This is the ergonomic way to “check and use” an optional in one step, instead of testing for null and then unwrapping separately. The same pattern-driven form exists for loops as while let.

Match

A match compares a value against a series of patterns and runs the first arm that fits. Each arm is pattern => expression, and arms are separated by commas.

match status {
    0 => print("idle"),
    1 => print("running"),
    2 => print("done"),
    _ => print("unknown"),
}

Like everything else in this section, match is an expression — every arm yields a value, and the whole match evaluates to it:

let label = match status {
    0 => "idle",
    1 => "running",
    _ => "unknown",
};

Patterns

mimas supports a rich set of patterns:

match value {
    0 => "zero",                 // literal
    1 | 2 | 3 => "small",        // multiple literals (an "or" pattern)
    n if n > 100 => "huge",      // a guard — an extra boolean condition
    found? => found.label,       // null-bind: matches & binds when `value` is not null
    Point { x = 0, y } => y,     // struct destructure (note: `=`, like construction)
    Shape::Circle(r) => area(r), // enum-variant destructure
    (a, b) => a + b,             // tuple destructure
    other => fallback(other),    // a bare name binds anything (the catch-all)
}

A few notes worth calling out:

  • Struct and variant patterns mirror construction. Fields use = (Point { x = 0 }), not :. Write just the field name to bind it (Point { x, y } binds both x and y).
  • A bare identifier matches anything and binds the value to that name — this is your wildcard / default arm. _ works too when you don’t need the binding.
  • Guards (n if cond) add a runtime condition to an arm.

Exhaustiveness

mimas checks matches for exhaustiveness using the same Maranget-style analysis as Rust: the compiler proves that every possible value is covered.

enum Flag { On, Off }

match flag {
    Flag::On => {},
    // compile error: non-exhaustive match — missing `Flag::Off`
}

Types with a finite shape — bool, enums, tuples, single-variant structs, options — can be exhausted by listing their cases. Open-ended types like int, float, and str can’t, so they always need a catch-all.

You have two ways to deliberately not enumerate every case.

A catch-all arm

A bare identifier (or _) at the end soaks up everything that’s left, which satisfies the exhaustiveness check:

match flag {
    Flag::On => do_thing(),
    _ => {},
}

The ! terminator

When you’re certain the remaining cases can’t occur, end the match with a bare !. It promises exhaustiveness and, if execution ever actually reaches it, raises a runtime error.

let n = match command {
    "go" => 1,
    "stop" => 2,
    ! // if it's neither, we made a promise we couldn't keep — error at runtime
};

Because the ! arm has the never type, it doesn’t add to the match’s result type. The expression above is a plain int.

Guards don’t count toward exhaustiveness

An arm with a guard might not fire, so the compiler can’t treat it as covering its pattern. You’ll still need a catch-all even when a guarded arm “looks” total:

match flag {
    Flag::On => {},
    Flag::Off if quiet() => {}, // doesn't fully cover `Flag::Off`
    _ => {},                    // still required
}

Loop

loop is the simplest loop: it repeats its body forever until something stops it. Use break to exit and continue to skip to the next iteration.

loop {
    if done() {
        break;     // leave the loop
    } else {
        continue;  // jump straight to the next pass
    }
}

Loops are expressions

A loop evaluates to whatever value you break with. This makes it a clean way to “retry until you get a result”:

let answer: int = loop {
    let guess = next_guess();
    if is_valid(guess) {
        break guess; // the loop evaluates to this
    }
};

The result type depends on how the loop ends:

The loop…evaluates to
break valuethe value’s type, e.g. int
break with no value()
never breaks (loop {})!, the never type

```admonish note title=“break value is for loop only” Plain loop is the one form guaranteed to run, so it can hand back a T directly. while and for might never enter their body, so a value they break out becomes a T? instead — see those pages.

While

A while loop checks a bool condition before each pass and stops as soon as it fails.

let i = 0;
while i < 5 {
    i += 1;
}
// -> i is 5

Breaking a value

Because a while loop might never run — its condition could be false on the very first check — a value broken out of it is wrapped in an option. The loop yields null if it ends without a break value.

let found: int? = while has_next() {
    let x = next();
    if matches(x) {
        break x; // -> int?, because the loop might not have run at all
    }
};

While let

while let is to while what if let is to if: instead of a bool, it evaluates an expression each pass and keeps looping as long as the result is not null, binding the unwrapped value for the body.

while let job = next_job() {
    // runs as long as `next_job()` returns a value;
    // stops the first time it returns null
    process(job);
}

It’s the idiomatic way to drain a source that signals “nothing left” with null.

For

A for loop walks over the elements of something iterable, binding each one in turn.

let xs = [10, 20, 30];
for x in xs {
    print(x); // 10, then 20, then 30
}

What you can iterate

IterableEach binding is…
[T] (array)an element, T
~{V} (dict)a (str, V) pair of key and value
streach character, as a one-character str
intthe numbers 0 up to (but not including) the value
for pair in ~{ x = 1, y = 2 } {
    print(pair); // [x, 1], then [y, 2]
}

for c in "hi" {
    print(c); // "h", then "i"
}

for i in 3 {
    print(i); // 0, 1, 2
}

Need the index?

Call .enumerate() on an array to pair each element with its position:

for pair in ["a", "b"].enumerate() {
    print(pair); // [0, a], then [1, b]
}

Tuples are intentionally not iterable: each position can hold a different type, so a single loop binding would have no consistent type. Reach into a tuple by index instead (t.0, t.1). See Tuples.

Breaking a value

Like while, a for loop isn’t guaranteed to run — the collection might be empty — so a value it breaks comes back as an option.

let first_big: int? = for n in numbers {
    if n > 100 {
        break n; // -> int?, since `numbers` could be empty
    }
};

A for loop that never breaks a value evaluates to (). To build a value out of every iteration instead of breaking once, use collect.

Collect

collect is a control-flow operator unique to mimas. It turns any loop into an array builder: each value you collect is appended to a running list, and when the loop finishes — whether it runs out of iterations or hits a break — the whole loop evaluates to that array.

Think of it as continue that carries a value: like continue, it moves on to the next iteration.

let numbers = [1, 5, 9, 2];

let doubled = for x in numbers collect x * 2;
// -> [2, 10, 18, 4]

Loop bodies don’t have to be blocks, so that last example fits on one line — close to a Python list comprehension:

// python:  doubled = [x * 2 for x in numbers]
let doubled = for x in numbers collect x * 2;

Add an if to filter which values get collected:

let evens = for x in numbers {
    if x % 2 == 0 {
        collect x;
    }
};
// -> [2]

It composes with every loop, including while let, which is handy for gathering results from a source until it’s exhausted:

let ages: [int] = while let person = next_person() {
    collect person.age;
};

```admonish info title=“collect and break value don’t mix” A loop either builds a value with collect or breaks out with one, not both. Doing both in the same loop is a compile error, since the loop can’t be an array and a single value at once.

Collections

mimas has three built-in ways to group values: arrays (ordered, same-typed sequences), dictionaries (string-keyed maps), and tuples (fixed-size, mixed-type groups). The first two are growable containers with methods; the tuple is a lightweight structural type.

This section also covers the in operator for membership tests. For named product and sum types, see Structs and Enums.

Arrays

An array is an ordered, growable sequence of values that all share one type — like a list in Python or a Vec in Rust. Its type is written [T].

let xs: [int] = [0, 1, 2, 4];
let first = xs[0]; // 0 — index with []

Indexing is zero-based, and reading past the end is a runtime error:

let xs = [1, 2, 3];
let oops = xs[9]; // runtime error: index out of bounds

Arrays carry a set of built-in methods for inspecting and growing them, such as len, push, and contains:

let xs = [3, 1, 2];
xs.push(4);       // xs is now [3, 1, 2, 4]
let n = xs.len(); // 4

To build a new array by transforming or filtering an existing one, use a collect loop:

let doubled = for x in [1, 2, 3] collect x * 2; // [2, 4, 6]

Dictionaries

A dictionary is a hash map from string keys to values of one type, written ~{V}. The leading ~ distinguishes a dict literal from a block, since both use braces.

In a literal, keys are written as bare identifiers and values follow an =. You read a value back with ["key"].

let scores: ~{int} = ~{
    alice = 10,
    bob = 7,
};
let top: int? = scores["alice"]; // 10

Keys are strings

The identifier syntax in a literal (alice = 10) is sugar — the key is the string "alice", and you index with a string (scores["alice"]). Dictionaries are plain hash maps, not an ADT; for fixed, named fields with mixed types, use a struct.

Indexing returns an option

Any key might be absent, so indexing a dict hands back an option: ~{V} yields V?, never a bare V. A present key gives the value, a missing one gives null, and you handle that null like any other option.

let scores: ~{int} = ~{ alice = 10 };

let a: int? = scores["alice"]; // 10
let b: int? = scores["zoe"];   // null

let shown = b ?? 0; // 0 — fall back when absent

Iterating

Iterating a dict binds each entry as a (key, value) pair:

for pair in ~{ x = 1, y = 2 } {
    print(pair); // [x, 1], then [y, 2]
}

Dictionaries also carry built-in methods for membership and mutation, such as contains_key, insert, and remove.

Tuples

A tuple is a fixed-length, heterogeneous sequence — an anonymous product type where each position can hold a different type. Tuple types are written as a parenthesized list.

let pair: (int, int) = (0, 1);
let mixed: (float, str) = (0.1, "hello!");

Reach into a tuple by position with dot-and-index:

let mixed = (0.1, "hello!");
let x: float = mixed.0; // 0.1
let s: str   = mixed.1; // "hello!"

Because each position can be a different type, a tuple is not iterable — a single loop binding would have no consistent type to take. That’s the whole reason index access exists. To pull a tuple apart in one step, match on it:

let point = (3, 4);
let sum = match point {
    (a, b) => a + b,
};
// -> 7

You can also destructure a tuple straight into a let, binding each position at once:

let (x, y) = (3, 4); // x is 3, y is 4

Tuple vs. tuple struct

A tuple is anonymous and structural: (int, int) is just “two ints.” When you want that shape to carry a name and an identity — a Vec2 that isn’t interchangeable with every other (float, float) — reach for a tuple struct.

The in Operator

in tests for membership and evaluates to a bool. It works across the collection types and strings, adapting its meaning to each:

x in y where y is…tests whether…
[T] (array)the array contains the element x
~{V} (dict)the dict has the key x (a str)
strx is a substring of y
let nums = [0, 1, 2];
print(0 in nums);        // true

let dict = ~{ foo = 1 };
print("foo" in dict);    // true  — checks keys

let text = "hello!";
print("ell" in text);    // true  — substring
print("x" in text);      // false

For an array, x in xs is the operator form of xs.contains(x) — use whichever reads better at the call site.

Options

When a value is allowed to be either some type or null, it’s an option. Mark a type as optional by suffixing it with ?.

let a: int = 0;
a = null; // compile error: `a` is int, never null

let b: int? = 0;
b = null; // valid

An option is mimas’s answer to the billion-dollar mistake: null only exists where a ? invites it, so the compiler can guarantee you never trip over an unexpected null access.

An option is not its inner type

int? and int are different types. You can’t use a possibly-null value where a definitely-present one is required — you have to deal with the null case first (unwrapping, below).

let a: int = 0;
let b: int? = null;
a = b; // compile error: `b` may be null

Comparing options

You can compare an option for equality against null, or against a value of its inner type:

let maybe: int? = null;

print(maybe == null); // true
print(maybe == 0);    // false

Ordering comparisons (<, >, …), on the other hand, need both sides to be non-null — there’s no sensible place for null on a number line. Unwrap or coalesce first.

let a: int = 0;
let b: int? = null;
print(a > b); // compile error: these values can't be compared like numerals

Creating options

Any type coerces into its option form when it meets a null. So an array literal with a null in it, or an if whose branches disagree about presence, infers an optional type automatically.

let a = [0, null, 1];               // a is [int?]
let b = ~{ x = null, y = 0 };       // b is ~{int?}
let c = if ready { 0 } else { null }; // c is int?

The coercion only kicks in when a fresh value is created. It won’t quietly punch a null into an existing non-optional slot:

let d = [0];
d[0] = null; // compile error: expected int, found null

Flattening

There is no “option of an option.” If a type would come out as T??, mimas automatically flattens it to T? — the same choice Kotlin makes.

fn maybe() -> int? { null }

// `maybe()` is already int?, and the other branch is null, so this would be int??
// — mimas flattens it straight to int?.
let nested: int? = if ready { maybe() } else { null };

Option chaining

To reach through a value that might be null, you’d otherwise write a guard:

let len: int? = if name == null {
    null
} else {
    name.len()
};

The ?. operator collapses that into one expression. If the receiver is null, the whole chain short-circuits to null and the call is never made; otherwise it proceeds and re-wraps the result as an option.

let len: int? = name?.len();

The same works for indexing, with ?[ ]:

let grid: [[int]?] = [[0], null, [1]];
let cell: int? = grid[1]?[0]; // null — the middle row is null, so we stop

Unwrapping

When a code path expects an option to actually hold a value, you unwrap it. Where ? poses the question of an option, a postfix ! asserts the answer: “this is not null.” If it turns out to be null, that’s a runtime error.

let port: int = lookup_port()!; // `port` is a plain int from here on

When you’d rather supply a fallback than risk a runtime error, reach for ??, which yields its right side when the left is null:

let port: int = lookup_port() ?? 8080; // the value if present, otherwise 8080

And to branch on presence while binding the unwrapped value, use if let:

if let port = lookup_port() {
    connect(port);
} else {
    use_default();
}

When you want the unwrapped value for the rest of the scope and would rather bail out than nest, a let/else keeps things flat:

let port? = lookup_port() else {
    panic("no port configured");
};
// `port` is a plain int from here on

Results & Error Handling

mimas has two tiers of failure: an unrecoverable panic that tears down the VM, and a recoverable result that a caller can inspect and handle.

Panicking

The blunt instrument is panic. It immediately halts the VM with a message — use it for “this should never happen” situations.

panic("unreachable state");

todo is panic’s cousin for unfinished code; it halts the same way and even lets you omit the message.

fn not_done_yet() -> int {
    todo("implement me")
}

Since both diverge, they have the never type and slot into any expression — handy as a placeholder branch.

Results

For failure a caller can actually deal with, a function returns a result, written by suffixing its return type with !. Inside such a function, raise produces an error.

fn parse_port(text: str) -> int! { // the `!` lets this function `raise`
    let n = text.to_int();
    if n == null {
        raise "port must be a number";
    }
    n!
}

A few rules govern results:

  • raise is only legal inside a function whose return type is a result (T!). The value you raise must be a str — the only error type in 0.1.0.
  • A function returning T! can hand back a bare T; it’s automatically wrapped as the success case. (That’s why n! above — an int — is a valid int! return.)
  • raise itself has the never type, so it composes inside if and match arms without disturbing their result type.

```admonish todo title=“str errors today, typed errors later” In 0.1.0, the error carried by a result is always a str. The plan is to move to an Error pact you can implement for your own types, so failures can carry structured data. For now, a descriptive message is the tool.


## Handling a result

A result has to be dealt with before you can use the value inside it. There are two ways.

### Unwrap with `!`

The postfix `!` — the same operator that unwraps an [option](./options.md#unwrapping) — pulls the success value out of a result. If the result turned out to be a raised error, that becomes a runtime error and execution stops.

<pre class="hljs language-mimas"><span class="hljs-keyword">let</span> port: <span class="hljs-built_in">int</span> = <span class="hljs-title">parse_port</span>(<span class="hljs-string">&quot;</span><span class="hljs-string">8080</span><span class="hljs-string">&quot;</span>)<span class="hljs-keyword">!</span>; <span class="hljs-comment">// 8080 — or a hard stop if it had raised</span></pre>

```admonish warning title="`!` does not propagate — it unwraps or dies"
Coming from Rust, the `!` here is *not* the `?` operator. It doesn't bubble an error up to your caller; it asserts success and faults the VM if it's wrong. To actually *handle* a failure and keep running, use `absolve`.

Recover with absolve

absolve consumes a result and guarantees a plain value. You give it a closure that receives the error string; if the result raised, your closure runs and its value is used instead.

let port: int = parse_port(input) absolve |err| {
    print(f"bad config: {err}");
    8080 // fall back to a default
};

The closure’s return type must match the result’s inner type — absolve on an int! has to produce an int — so the overall expression is a guaranteed, non-failing value.

Functions & Closures

Functions are declared with fn. Parameters must be annotated; the return type follows -> and defaults to () when omitted.

fn greet(name: str) {
    print(f"hello, {name}!");
}

fn square(n: int) -> int {
    n * n
}

The body is a block, so its final bare expression is the return value. The annotated return type is enforced:

fn oops() {
    0 // compile error: expected (), found int
}

return exits early and, being never-typed, fits anywhere:

fn clamp_low(n: int) -> int {
    if n < 0 {
        return 0;
    }
    n
}

Optional parameters

A parameter with a default value becomes optional. Like a let, its type can be inferred from the default, so the annotation is optional too.

fn connect(host: str, port=8080) {
    // ...
}

connect("localhost");        // port defaults to 8080
connect("localhost", 9090);  // or pass it explicitly

Defaults must be const-foldable, and every optional parameter has to come after all the required ones.

fn bad(a: int, b=0, c: int) {} // compile error: a required parameter can't follow an optional one
fn good(a: int, c: int, b=0) {} // valid

Named arguments

Optional arguments may be passed by name — again, only once all the required positional arguments are supplied. Naming lets you skip over defaults you don’t care about and set one further along:

fn style(text: str, bold=false, italic=false, size=12) {}

style("hi", size=18, bold=true); // skip `italic`, set the other two

Closures

A closure is an inline, anonymous function written with pipes. Its return type is optional and inferred when left off.

let add = |a: int, b: int| -> int { a + b };
let double = |n: int| { n * 2 }; // return type inferred

let total = add(double(3), 4); // 10

A no-argument closure uses an empty pair of pipes:

let now = || current_time();

Note

Functions cannot currently be passed to native functions and called. This is a high priority to be added after v0.1.0.

Closures vs. functions

The two differ in two important ways.

First- vs. second-class. Closures are first-class: assign them to bindings, store them in arrays, pass them around freely. Functions are second-class — you can pass one as an argument, but you can’t bind or store it as a value.

let add = |a: int, b: int| -> int { a + b };
fn sub(a: int, b: int) -> int { a - b }

apply(add); // legal — passing a closure
apply(sub); // legal — passing a function as an argument is fine

let f = add; // legal
let g = sub; // compile error: a function isn't a value you can bind

Capturing. A closure can capture variables from the scope around it. A function cannot — it sees only its parameters and top-level items.

let greeting = "hello!";

let say = || print(greeting); // legal — captures `greeting`

fn say_fn() {
    print(greeting); // compile error: `greeting` is undefined in here
}

Functions live at the top level

fn declarations are only allowed at the root of a file. Need a function-like value deeper inside another function or a block? That’s exactly what closures are for.

fn outer() {
    fn inner() {}      // compile error: no nested functions
    let inner = || {}; // do this instead
}

User-Defined Types

Beyond the built-in primitives and collections, mimas lets you define your own named types. There are two kinds, and they cover the classic algebraic pair:

  • Structsproduct types. A struct holds several values at once: a Player has a name and a score and a position.
  • Enumssum types. An enum is exactly one of several shapes: a Shape is a circle or a rectangle or a triangle, never more than one.

Both gain behavior through impl blocks, and both can satisfy a pact to share an interface.

Structs

A struct is a named product type — it bundles several fields, each with its own type, under one name.

struct Player {
    name: str,
    score: int,
}

let p = Player {
    name = "ada",
    score = 0,
};

Construction uses = for each field (not :, which is reserved for type annotations). Read a field back with dot access:

print(p.name);  // "ada"
print(p.score); // 0

Methods and associated items

impl blocks attach behavior to a struct. A type can have any number of impl blocks, as long as the struct is in scope.

An impl can define associated constants, associated functions, and methods. A function becomes a method when its first parameter is self; Self refers to the struct being implemented.

struct Player {
    name: str,
    score: int,
}

impl Player {
    const MAX_SCORE = 100;

    // associated function — no `self`
    fn new(name: str) -> Self {
        Self { name = name, score = 0 }
    }

    // method — takes `self`
    fn is_winner(self) -> bool {
        self.score >= Self::MAX_SCORE
    }
}

let p = Player::new("ada");   // call an associated function with `::`
print(p.is_winner());         // call a method with `.`

Calling a method with dot syntax is just sugar — it passes the receiver in as self. These two lines are equivalent:

let won = p.is_winner();
let won = Player::is_winner(p);

Reaching items through a value

mimas has one deliberate split from Rust: dot access can also reach an associated item through a value, not just through the type name. This matters for pacts, where you have a value but not its concrete type name.

let p = Player::new("ada");
print(p.MAX_SCORE);      // 100 — the associated const, reached through the value
print(Player::MAX_SCORE); // the same const, through the type

Tuple structs

A struct can use positional fields instead of named ones, making it a tuple struct. Read its fields by index, like a tuple.

struct Vec2(float, float)

let v = Vec2(1.0, 2.0);
let x = v.0; // 1.0
let y = v.1; // 2.0

A tuple struct’s bare name doubles as a constructor value — a function (fields...) -> Struct you can pass around — while the name still works as a type in annotations.

struct Wrap(int)

let make = Wrap;       // `make` is a function value, (int) -> Wrap
let w: Wrap = make(7); // `Wrap` is still usable as a type
print(w.0);            // 7

A field-free struct is written with empty braces or a bare struct Name; — useful as a marker type or a home for associated items and pact impls.

struct Marker;

Enums

An enum is a named sum type: a value is exactly one of several variants. Each variant has its own shape and can carry data — as a tuple of types, as named fields, or as nothing at all.

enum Shape {
    Circle(float),           // tuple variant
    Rectangle(float, float), // tuple variant
    Labeled { text: str },   // struct variant
    Empty,                   // payload-free variant
}

let a = Shape::Circle(1.0);
let b = Shape::Rectangle(2.0, 3.0);
let c = Shape::Labeled { text = "square" };
let d = Shape::Empty;

Variants are namespaced under the enum with ::. A payload-free variant like Shape::Empty is used directly as a value; the others are constructed by supplying their data — positionally for tuple variants, with = for struct variants (mirroring struct construction).

An enum is always one of its variants

You can’t make a bare Shape() — there’s no such thing as an enum value that isn’t one specific variant. Construct through a variant, always.

Matching on variants

match is how you take an enum apart: each arm names a variant and binds its payload. Because the compiler knows the full variant list, it can check that you’ve covered them all.

fn area(s: Shape) -> float {
    match s {
        Shape::Circle(r) => 3.14159 * r * r,
        Shape::Rectangle(w, h) => w * h,
        Shape::Labeled { text } => 0.0,
        Shape::Empty => 0.0,
    }
}

Methods

Like structs, enums take impl blocks for associated items and methods. A method that dispatches on self is the idiomatic way to fold behavior into the type itself:

impl Shape {
    fn area(self) -> float {
        match self {
            Shape::Circle(r) => 3.14159 * r * r,
            Shape::Rectangle(w, h) => w * h,
            _ => 0.0,
        }
    }
}

let total = Shape::Circle(2.0).area(); // 12.56636

Pacts

mimas is built for embedded use, so Rust’s full trait machinery — generics, associated types, blanket impls, and the constraint solver behind them — is intentionally out of scope. Even so, abstracting over types is sometimes genuinely useful: without any way to do it, every function that wants to handle “anything with this behavior” has to be rewritten per concrete type.

It comes up most at the FFI line. A host project large enough to span several crates often leans on traits at its boundaries, and a mimas script needs some way to interoperate with at least the simplest of those patterns.

A pact fills that role. It’s a named set of method, associated-function, and constant signatures — a contract a type satisfies by writing impl PactName for TheType { ... } and supplying the listed items. Anywhere a type is expected, a pact can stand in, and the checker will accept any value whose type fulfills it.

pact Draw {
    fn draw(self);
}

struct Square;
impl Draw for Square {
    fn draw(self) { /* draw a square */ }
}

struct Circle;
impl Draw for Circle {
    fn draw(self) { /* draw a circle */ }
}

fn render_all(items: [Draw]) {
    for item in items {
        item.draw();
    }
}

Constants

A pact can require constants as well as methods.

pact Named {
    const NAME: str;
}

impl Named for Square {
    const NAME = "Square";
}

let n = Square::NAME; // "Square"

Default implementations

A pact item can ship with a default, which an implementer may use as-is or override.

pact Identified {
    const ID: int;

    fn describe() {
        print(f"my id is {Self::ID}");
    }
}

impl Identified for Square {
    const ID = 0;
    // `describe` is inherited
}

Square::describe(); // "my id is 0"

Reaching pact items through a value

Without generics, mimas can’t lean on Rust’s T::ITEM syntax to read an associated item off a constrained type. This is why dot access reaches associated items — given a value known only by its pact, you still need a way to get at its constants and methods, and . is it.

fn announce(thing: Identified) {
    let id = thing.ID;
    thing.describe();
}

Binding multiple pacts

An annotation can require several pacts at once with +:

struct Widget {
    item: Named + Identified,
}

To make a multi-pact bound optional or a result, wrap it in parentheses first so the ?/! applies to the whole thing:

struct Widget {
    item: (Named + Identified)?,
}

Privacy

Privacy in mimas is drawn at the module boundary. Everything is private to its own module by default; the pub keyword is what makes an item — or a struct field — reachable from other modules.

pub fn api() {}      // callable from other modules
fn helper() {}       // module-private

pub struct Config {
    pub name: str,   // readable from other modules
    secret: str,     // module-private field
}

Within a module, there are no walls

pub only matters when one module reaches into another. Inside a single module (including a plain script file), every item and field is freely accessible — privacy never gets in your way locally. The examples below assume the access is happening from a different module.

Private fields

From outside its module, a struct’s private fields are neither readable nor writable. That also means you can’t build the struct with a literal — you’d have to name fields you aren’t allowed to touch.

// in another module:
let c = Config { name = "x", secret = "y" }; // error: `Config::secret` is not accessible
let s = c.secret;                            // error: `Config::secret` is not accessible

The fix is to expose a public surface — a constructor and accessors — and keep the internals sealed:

impl Config {
    pub fn new(name: str) -> Self {
        Self { name = name, secret = generate() }
    }

    pub fn name(self) -> str {
        self.name
    }
}

// in another module:
let c = Config::new("x"); // ok
print(c.name());          // ok

This is the standard encapsulation move: callers get a stable, intentional interface, and you stay free to change the private guts.

Modules

A module is a named collection of items — functions, constants, types — that you can share across files. Modules are how a mimas project grows past a single script: they group related code and draw the privacy boundary that pub controls.

Declaring a module

A file becomes a module with a module declaration at the top. Name it explicitly, or use @ to take the file’s own name (so colors.mim becomes the colors module).

module colors;
// equivalently, in colors.mim:
module @;

const INTERNAL = 0; // private to this module
pub const RED = "#ff0000";
pub fn mix(a: str, b: str) -> str { /* ... */ }

Modules can nest by qualifying the path with :::

module graphics::colors;

Module files don’t run

A file that declares a module is a library, not a script — it can’t have top-level expressions to execute. That keeps “code that runs” and “code that’s organized for reuse” cleanly separated. See Scripts.

Using a module

Bring a module into scope with use. By default this makes the module available under its name, and you reach its public items with :: — the same accessor structs use.

use colors;

let r = colors::RED;
let m = colors::mix(colors::RED, "#00ff00");

let i = colors::INTERNAL; // error: `INTERNAL` is private to its module

You can also pull items directly into the current file. Import one item, several at once with braces, or everything with *:

use colors::RED;          // just `RED`
use colors::{ RED, mix }; // several names
use colors::*;            // every public item

let r = RED;
let m = mix(RED, "#00ff00");

Scripts

There is no implicit main function in mimas. Unlike Rust, a script just runs top to bottom — write whatever code you like at the file’s root and execute it directly, the way you would with Python or JavaScript.

// hello.mim
print("Hello, world!"); // runs the moment you execute the file
mimas run hello.mim

Top-level statements run in order, and top-level fns, structs, and the like are available throughout the file regardless of where they’re declared.

print(greet("world"));      // works — `greet` is visible across the whole file

fn greet(name: str) -> str {
    f"hello, {name}!"
}

Scripts run; modules organize

A file that opens with a module declaration is a library, not a script — it can’t carry top-level expressions to execute. This split keeps “the thing you run” distinct from “the code you structure for reuse.”

Memory

mimas is garbage collected. Memory is managed for you by a collector built on gc-arena — there is no manual allocation, no free, no pointers, and no lifetimes to thread through your code. You create values and use them; the runtime reclaims what you no longer reach.

This is a deliberate part of mimas’s ethos: pair a forgiving runtime with a strict-but-helpful compiler. The static analysis mimas does is spent on correctness — types, exhaustiveness, null-safety — not on bookkeeping who owns what. The goal is a language that feels like scripting to write while still catching real mistakes before you run.

Where the heavy lifting belongs

If a piece of your project genuinely needs ownership, lifetimes, or manual control over memory, that’s a strong sign it belongs on the Rust side of the boundary. mimas is the flexible scripting layer; Rust remains the place for the parts that demand that rigor. See Extension with Rust.

Notable Exclusions

Anything not described in this reference can be assumed not to exist in mimas — the language is intentionally small. This page calls out a few absences that programmers coming from other languages are most likely to reach for, and why they’re left out.

Pointers & references

There are no pointers or references. They’re a sharp tool that invites whole categories of instability, and guarding against that safely would demand a lifetime system as involved as Rust’s. Like Python, mimas’s answer is simply not to have them. If part of your project genuinely needs that level of control, that’s a sign it belongs on the Rust side. (Memory covers the managed model that replaces them.)

Union types

Arbitrary unions (A | B) undercut type inference and blur the line between static and dynamic typing in ways that get unpleasant fast. Where you’d reach for a union to write code generic over several types, mimas points you at a pact instead. The only built-in “this or that” types are options and results, which are deliberate, well-behaved special cases.

Generics

mimas has no user-facing generics. Abstracting over types is the job of pacts, which cover the common cases without the inference cost and complexity that a full generic system brings to an embeddable language.

A borrow checker

There is no borrow checker — garbage collection removes the need for one. Trading manual memory rigor for a managed runtime is exactly what buys mimas its forgiving, script-like feel.

A let / mut split

There’s no mut. Without a borrow checker there’s little to gain from distinguishing mutable and immutable bindings, so the model is as simple as it gets: let is reassignable, const is not, and that’s all. See Variables & Constants.

Extention with Rust