The Book of Mar
Foreword
This book teaches the Mar programming language, but it is not a reference manual. It is a book about why.
Mar makes a series of unusual choices. Values cannot be changed after they are created. Functions are not allowed to secretly talk to the network. There is no null, no exceptions, and no floating-point numbers. The user interface is built from a fixed vocabulary instead of HTML and CSS. The backend and the frontend live in the same codebase and compile together.
Each of these choices sounds like a restriction, and each one is. The purpose of this book is to show what you buy with them: whole categories of bugs that stop being possible, a compiler that acts like a careful reviewer, an app that runs on the web, on iOS, and on a server without three teams keeping them in sync.
Who this book is for
You already know how to program. You have shipped things in JavaScript, Python, Java, C#, Go, Ruby, or something like them. You are comfortable with functions, objects or structs, HTTP, and databases.
What we do not assume is any experience with functional programming. Words like “immutability”, “pure function”, and “pattern matching” are explained from zero, always with the same question in mind: what problem does this solve for someone building a real app?
How to read it
- Part I, Thinking in Mar, is the heart of the book. It covers the ideas that make Mar feel different: immutable values, pure functions, expressions, types, and Mar’s unusual choice of only exact numbers,
IntandDecimal, with no floating point. Read it in order. - Part II, The Architecture, explains how those ideas assemble into applications: the Model-View-Update loop on the frontend, and services, entities, tasks, and auth across the stack.
- Part III, The Surface, covers what you see and ship: the UI vocabulary, the canvas and sound modules that power games, and deployment.
- Part IV closes with an honest discussion of trade-offs.
Code samples are real Mar, matching the language as it exists today. When a sample is a fragment, it is a fragment of a shape you will recognize from the chapters before it.
One warning before we start: Mar will occasionally refuse to compile a program you are sure is fine. Stay with it. In almost every case the compiler has found a future 2 a.m. incident and is declining to schedule it.
What Is Mar?
Mar is a language for building full-stack applications: the kind with a database, a server, signed-in users, and a user interface that runs in a browser and on a phone. You write the whole thing, backend and frontend, in one language, in one codebase, and it compiles into everything you need to ship.
one codebase → a web bundle
→ a native iOS app
→ a single server binary with SQLite, auth, and an admin panel inside
The problem Mar is aimed at
Think about what a small, ordinary product is made of today: a frontend framework, a language for it, a bundler, a backend language, an HTTP framework, an ORM, a database, a migration tool, an auth provider, JSON schemas or an API-types package to keep the two halves agreeing, and a deployment story for each piece.
None of these pieces is bad. The trouble is the seams between them. The frontend believes the API returns dueDate; the backend renamed it to due_date last Tuesday. The ORM model drifted from the actual table. The auth middleware runs on every route except the one where it matters. Every seam is glue, every glue joint is invisible to the type checker, and every invisible joint fails at runtime, in production, at night.
Mar’s founding observation is that most of these seams are not essential. They exist because we build one product out of several languages and toolchains that cannot see each other. Put the whole application in one language, and the seams become ordinary function calls and shared types, which a compiler can check.
The Mar project describes this with a metaphor from Japanese woodworking: sashimono, joinery without nails or glue. Each piece is shaped to fit the others. There is no glue to fail because there is no glue.
What that looks like in practice
A few concrete consequences, each of which gets its own chapter later:
- One definition of every type. The
Taskrecord your database stores, your server returns, and your page renders is the same declaration. Rename a field and the compiler walks you through every place that must change, across the entire stack. - API calls are typed function calls. A service is declared once, with its request and response types, and both halves import that declaration. There is no JSON handling in your code, and there is no way for the two sides to disagree and still compile.
- The scaffolding is included. Authentication (passwordless email codes), authorization, database, migrations, backups, rate limiting, and an admin panel ship with the runtime. Day one of a Mar project is spent on your product, not on wiring.
- The UI is written once. Pages are composed from a fixed vocabulary of elements. The web runtime renders them as careful HTML and CSS; the iOS runtime renders them as native SwiftUI. You do not write either.
- Deployment is boring. A fullstack app compiles to one self-contained executable, assets and database engine included. A frontend-only app compiles to a folder of static files.
The kind of language Mar is
Mar belongs to the ML family of languages, with syntax and discipline closely modeled on Elm. If you have not met this family, here is the character sketch; Part I develops each point properly.
- Purely functional. Programs are built from functions that compute values from values. Anything that touches the world (the database, the network, the clock) is described as data and executed by the runtime, never performed secretly inside your logic.
- Immutable. A value, once created, never changes. “Change” means computing a new value.
- Statically typed, with inference. Every expression has a type checked at compile time, but you rarely write type annotations; the compiler works them out (the technique is called Hindley-Milner inference). Union types and exhaustive pattern matching replace
nullchecks and exception handling. - Interpreted by its runtime, by design. Mar programs are shipped as a compact program description that each platform’s runtime executes: JavaScript on the web, Swift on iOS, Go on the server. This is what makes one language on three platforms tractable, and it is fast enough that entire 60fps games are written in it.
What Mar is not
Honesty early saves disappointment later.
- Mar is not a systems language. There is no manual memory management and no C interop. You will not write a database engine in it (Mar’s own engine is written in Go, underneath the language).
- Mar is not a general-purpose UI toolkit. The view vocabulary is opinionated. If your product needs a bespoke design system with custom CSS on every pixel, Mar will feel like a straitjacket.
- Mar is not runtime-dynamic. There is no
eval, no monkey patching, no reflection. Everything is known at compile time. This is a feature, but it is fair to note it also closes doors. - Mar is young. The ecosystem is small, the language is evolving, and breaking changes still happen between versions.
The right mental model: Mar trades breadth for depth. It covers one shape of software, the typed application with a UI, a server, and a database, and it tries to cover it end to end, better than a pile of glued parts can.
The next chapter installs the toolchain and runs a real project, so the rest of the book has something concrete to point at.
Getting Started
Mar’s toolchain is one binary, mar. It is the compiler, the type checker, the dev server, the formatter, the test runner for your project’s shape, and the deploy tool. This chapter takes you from nothing to a running app, and names each moving part so later chapters can refer to them.
Install
Download the mar binary for your platform from the releases page and put it on your PATH. There is no package manager, virtual environment, or runtime to install; day-to-day development happens on macOS and Linux.
Check it works:
$ mar version
For syntax highlighting and completions, install the editor plugin: VS Code or Sublime Text.
Scaffold a project
$ mar init my-app
mar init asks which starter you want (a fullstack app with auth, a frontend-only app, and more) and writes a working project. The fullstack layout looks like this:
my-app/
mar.json manifest: name, server port, database path, mail, auth
Main.mar entry point: App.fullstack { services, pages }
Shared.mar types and Service contracts used by BOTH halves
Backend/
Users.mar entities and service handlers
Tasks.mar
Frontend/
Routes.mar typed paths
SignIn.mar one MVU page per file
Home.mar
Three things to notice, because they carry the philosophy:
Shared.marexists. Types and service contracts that both halves need live in one module both import. This file is the reason the frontend and backend cannot drift apart.Main.maris the only module that sees both halves. It hands the list of services and the list of pages toApp.fullstack. Everything else is either backend, frontend, or shared.mar.jsonis configuration, not code. Ports, database path, SMTP settings. Secrets must be environment references like"env:SMTP_PASSWORD"; a literal secret in the manifest is a compile error.
Run it
$ cd my-app
$ mar dev
mar dev boots a hot-reload development server. Edit any .mar file and the browser updates in place. Three details worth knowing already:
- The compiler is watching. Save a file with a type error and the browser shows the error, nicely formatted, where your app was. Fix it and the app returns. You will spend real time reading these messages; they are written to be read.
- The database is migrating. On startup, Mar compares your entity declarations to the actual SQLite schema and applies safe migrations automatically. Destructive changes refuse to run and tell you why. You never write migration files.
- The admin panel is live at
/_mar/admin: your entities, browsable and editable, with no code written.
There is also mar check (type-check without running) and mar build (produce the deployable artifacts).
Deploy it
$ mar deploy
One command, reading mar.json to decide what to ship:
- A fullstack app compiles to a single self-contained binary (your code, the runtime, SQLite, static assets) and ships to a Fly.io VM.
- A frontend-only app compiles to a folder of static files and ships to Cloudflare Pages.
Chapter 11 covers what is actually inside those artifacts and why the single-binary model was chosen.
The shape of every chapter ahead
You now have the pieces on the table: a manifest, a shared module, backend modules with entities and handlers, frontend modules with pages, and a runtime that turns them into a product. The rest of the book explains the ideas that make those pieces fit without glue, starting with the most fundamental one: values that never change.
A New Way to Think
If you come from JavaScript, Python, Java, or Go, the biggest adjustment in Mar is not the syntax. It is that a handful of things you do every day, reassigning a variable, mutating an object, returning early, throwing an exception, checking for null, quietly calling the network from anywhere, are simply not in the language.
This part of the book explains what replaces each of them, and why the trade is worth making. The five chapters build on each other:
- Values That Never Change: every value in Mar is immutable. What “change” means when nothing changes, and why this eliminates a whole family of bugs.
- Functions You Can Trust: every function computes its output from its inputs, and nothing else. Where side effects go instead.
- Everything Is an Expression:
ifandcaseproduce values, there are no statements, and why “no early return” turns out to be a gift. - Types Without Ceremony: records, unions,
Maybeinstead ofnull,Resultinstead of exceptions, and a compiler that infers almost everything. - Integers and Decimals: Mar has no floating-point numbers, only exact ones. This sounds radical; it is one of the most practical decisions in the language.
A note on posture. Each of these ideas can be described as taking something away, and defenders of functional programming sometimes make it sound like a moral improvement. This book takes a different line: each restriction is a purchase. You pay with a habit; you receive a guarantee. The chapters try to be concrete about both sides of the transaction.
Values That Never Change
In Mar, every value is immutable. Not “immutable by convention”, not “please use const”: there is no assignment operator, no way to modify a record in place, no way to push onto a list. A value, once created, is what it is forever.
If you have spent years mutating variables, this sounds like being asked to cook without heat. This chapter explains what “change” means in an immutable world, why the restriction pays for itself, and why it does not have the performance cost you probably suspect.
The problem with things that change
Consider a line you have written a thousand times:
// JavaScript
user.name = "Ana";
Innocent. But answer honestly: who else has a reference to user? Whatever list it sits in, whatever cache holds it, whatever component rendered it, all of them just changed too, silently. Mutation is action at a distance: a write in one place is a surprise in every other place that shares the value.
Almost every “how did the state get like this?” bug is this pattern. Something, somewhere, changed a value that something else was relying on. The debugging session is an archaeology dig: not “what is wrong” but “who touched this, and when”.
Immutability deletes the question. If no value ever changes, then whatever a function received is exactly what it holds for as long as it wants. Nobody can pull the rug.
What “change” becomes
Programs still need to express change: a task gets renamed, a counter goes up. In Mar you do not modify the old value; you compute a new one that differs where you say:
rename : String -> Task -> Task
rename newName task =
{ task | name = newName }
{ task | name = newName } is record update syntax: “a new record, same as task, except name”. The original task is untouched; both values exist, and each reference keeps meaning what it meant.
Lists work the same way. There is no push; there are functions that produce new lists:
withDone : List Task -> List Task
withDone tasks =
List.filter (\t -> t.done) tasks
The habit shift is real but small: instead of “reach in and change it”, you write “here is the new whole”. After a week it stops feeling like a restriction; it starts feeling like the difference between editing a shared document and sending versioned copies. Nobody’s edits collide, because there are no edits.
Where state actually lives
An application obviously has state: the current user, the list on screen, the score. In Mar, state lives in one value, called the Model, and the runtime replaces it wholesale as your program computes new versions. Chapter “The Loop” makes this precise; the important idea here is the division of labor:
- Your code: pure computation from old value to new value.
- The runtime: the single mutable cell that swaps old for new.
All the mutation in a Mar program happens in one place you never touch, which means “who changed this?” always has the same answer: your update function, on the previous line of the message log.
The payoff, concretely
Bugs that stop existing. Shared-mutable-state bugs, iterator invalidation, “changed during render”, stale caches of live objects, defensive copying because you cannot trust a callee: all of these require mutation to happen. No mutation, no bug class.
Fearless sharing. Because nothing changes, sharing is always safe. Pass your whole Model to a function; it cannot damage it. Keep the old Model next to the new one; they cannot interfere.
Time travel, for free. Mar’s dev mode keeps every Model your app has ever had and lets you scrub back and forth through them like video. This is not a heroic engineering feat; it falls out of immutability. Old states are just values, and values keep.
Honest equality. When values cannot change, “equal now” means “equal forever”, so caching and change-detection become trustworthy.
“But copying everything must be slow”
The standard objection, and the answer is structural sharing. When you write { task | name = newName }, Mar does not deep-copy the record; the new value reuses everything from the old one except the field that differs. A new list that adds an element in front of an old list shares the old list entirely. Because nothing can be mutated, sharing is undetectable and therefore always safe. That is the trick: immutability is what makes the cheap implementation correct.
Is it literally as fast as mutating a field in C? No. Is it fast enough that entire 60-frames-per-second games, with hundreds of units doing pathfinding, ship in Mar today? Yes, and you will meet them in the Canvas chapter. For the CRUD apps, dashboards, and tools Mar targets, the difference is noise.
The mental model to keep
A Mar program never has “an object at time T”. It has values, and functions from values to values. Time is modeled the only honest way: as a sequence of distinct values, each one complete, none of them ever revised.
Everything else in this book leans on that sentence. The next chapter applies the same discipline to functions themselves.
Functions You Can Trust
The second discipline: in Mar, a function’s output depends on its inputs, and calling it does nothing except produce that output. No hidden reads, no hidden writes, no surprises. This property is called purity, and Mar does not merely encourage it; the language is arranged so that impurity has nowhere to hide.
What purity means
A function is pure when both of these hold:
- Same inputs, same output. Always. Not “usually”. A pure function consults nothing but its arguments: no globals, no clock, no database, no random source.
- No side effects. Calling it changes nothing in the world: no network request, no file write, no log line, no mutation (the previous chapter already removed that one).
totalCents : List Item -> Int
totalCents items =
List.sum (List.map (\i -> i.priceCents * i.qty) items)
You can know what this function does by reading it. Not guess: know. It cannot charge a card, cannot depend on Tuesday, cannot work in dev and fail in prod because of environment differences. Its type signature, List Item -> Int, is the whole story of its relationship with the universe.
That reliability compounds. A program assembled from pure functions can be understood one function at a time, tested with plain asserts (no mocks, nothing to stub), refactored by substitution (any expression can be replaced by its value, a property with the grand name referential transparency), and cached or reordered by the runtime without changing behavior.
But programs must touch the world
An app that cannot write to the database is a space heater. The functional trick, and the single most important idea in this part of the book, is:
An effect is described as a value, and the runtime performs it.
Your code never does the effect; it returns a description of the effect. Two types carry these descriptions, one per half of the app:
Cmd msgon the frontend: “runtime, please do this work, and deliver the outcome to me as a message.” Calling a service, navigating, asking for the time.Task aon the backend: “a computation that, when run, produces ana.” Reading rows, writing rows, getting the current time.
Here is a frontend fragment. Note carefully what update does and does not do:
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
AddClicked ->
( { model | submitting = True }
, Service.call Shared.addTask { name = model.draft } Added
)
Service.call ... does not perform an HTTP request. It builds a value describing one: which service, which payload, and which message (Added) should carry the result back. update stays pure: given this message and this model, it always returns this pair. The runtime receives the pair, swaps in the new model, performs the described request, and later feeds Added (Ok ...) or Added (Err ...) back through update.
The same idea on the backend, where Task values chain:
toggle : Int -> Task (Maybe Task)
toggle id =
let
found <- Repo.findById tasks id
in
case found of
Just task -> Repo.update tasks id { done = task.done == False }
Nothing -> Task.succeed Nothing
Repo.findById returns a Task, a description of a read. The let ... <- syntax chains: “when that value arrives, continue here.” The handler as a whole is a recipe the runtime executes; the logic inside stays a pure arrangement of that recipe.
Why bother with the ceremony?
Because “effects are values” buys three things that “just call fetch()” cannot:
The type system sees your effects. A function returning Task (List Task) visibly touches the world; a function returning List Task -> Int visibly does not. Impurity cannot hide in a helper three calls deep, because it would show in every signature on the way up. Mar even uses this for policy: a service declared with the GET verb is rejected at compile time if its handler reaches a write like Repo.update, because writes are visible in the types.
Effects become data you can manipulate. Values can be put in lists, batched, returned from case branches, built conditionally. “Do these three things” is Cmd.batch [a, b, c], an ordinary value built by ordinary code.
Testing and replay become trivial. Since update only describes effects, you can drive it with any sequence of messages and inspect the results, no network required. Mar’s time-travel debugger replays your session by refeeding messages, which only works because nothing in your code fires effects on its own.
The line in the sand
Everything inside your functions: pure. Everything at the edge, HTTP, database, clock, randomness: described by your functions, performed by the runtime. You will see this division called managed effects, and it is the load-bearing wall of the whole architecture. MVU on the frontend and services on the backend are both just shapes built against it.
One practical note before moving on: purity makes where state can change boring, and the next chapter makes how code flows equally boring. Boring, in both cases, is the point.
Everything Is an Expression
Languages like JavaScript and Python are built from two kinds of thing: expressions, which produce values (a + b, f(x)), and statements, which do things (if, for, return, assignment). Mar removes the second kind. Every construct in the language is an expression: it produces a value, and it composes with everything else.
This sounds like a syntax trivia point. It changes how programs are shaped.
if produces a value
In Mar, if is not a fork in control flow; it is a choice between two values:
label : Int -> String
label n =
if n == 1 then "1 item" else String.fromInt n ++ " items"
Because if yields a value, both branches must exist and must have the same type. There is no “forgot the else” bug: a missing branch is a missing value, and the program does not compile. The ternary operator from other languages is not a special case here; it is just what if always is.
case is switch grown up
case inspects a value against patterns, and each branch produces the result:
statusLine : Status -> String
statusLine status =
case status of
Open -> "Open"
InProgress -> "In progress"
Done -> "Done"
Two properties make case the workhorse of Mar programs. First, patterns can destructure, binding the parts of a value as they match (Just user -> binds user). Second, the compiler checks exhaustiveness: cover every constructor or the program does not compile. Add a fourth Status next month and every case that needs updating becomes a compile error, a to-do list written by the compiler. The types chapter leans hard on this.
let names intermediate values
let ... in introduces local names for readability. It is still an expression: the whole block has the value of its body.
priceLabel : Cart -> String
priceLabel cart =
let
subtotal = totalCents cart.items
shipping = shippingFor cart
in
formatMoney (subtotal + shipping)
Names in a let are definitions, not assignments; each is defined once, and there is no “reassign it below” (there is nothing to reassign; values do not change).
No early return, and why that is fine
There is no return in Mar. A function’s body is one expression, and the value of that expression is the answer. Coming from imperative guard clauses, this feels claustrophobic for about a day, and then you notice what you got:
Every path is visible in the shape of the code. An early return is control flow that exits sideways; readers must simulate the function line by line to know what runs. An expression has no sideways. if and case show every outcome as an indented branch, and the compiler has confirmed they are all there.
The guard-clause instinct maps onto case cleanly:
greeting : Maybe User -> String
greeting maybeUser =
case maybeUser of
Nothing -> "Welcome, stranger"
Just user -> "Welcome back, " ++ user.name
No loops, and what replaces them
There is no for and no while. Iteration is done by functions over collections:
names = List.map (\u -> u.name) users
adults = List.filter (\u -> u.age >= 18) users
List.map transforms every element; List.filter keeps some; List.foldl reduces a list to one value; and a dozen relatives cover the shapes between. What you lose is the freeform loop body with its accumulating mutations; what you gain is that each operation names its intent. filter cannot accidentally also transform. A map cannot break out halfway. Reading List.filter (\t -> t.done) tasks takes exactly as long as understanding it.
When no library function fits, recursion is the general tool: a function that calls itself on a smaller input. It appears rarely in application code, but it is there, and the games use it freely.
Pipelines: reading top to bottom
Nested calls read inside-out: f (g (h x)). The pipe operator |> feeds a value to a function, so the same computation reads in the order it happens:
activeNames : List User -> List String
activeNames users =
users
|> List.filter (\u -> u.active)
|> List.map (\u -> u.name)
|> List.sort
Pipelines are everywhere in idiomatic Mar. They are not magic; x |> f is exactly f x. They exist because code is read far more often than it is written.
What this adds up to
Statements let a function be a little machine with private choreography: set this, maybe return, loop, mutate, bail. Expressions force a function to be a derivation: the answer, in terms of the inputs, all branches accounted for. Combined with immutability and purity, this is why you can read a Mar function you have never seen and trust your reading. The compiler has already checked the parts human reviewers are worst at: the missing branch, the forgotten case, the path that returns nothing.
Next: the type system that makes those checks possible without you writing types everywhere.
Types Without Ceremony
Static types have a reputation problem, earned in languages where they mean writing ArrayList<Map<String, Object>> three times per line. Mar’s type system is a different animal: it is inferred, so you rarely write types at all, and it is expressive, so the types you do write describe your domain instead of your memory layout.
The goal of this chapter is to change what you expect from a type checker: from “pedantic form-filling” to “a reviewer that reads every line and never gets tired”.
Inference: the compiler writes the types
Mar uses Hindley-Milner type inference. Write code with no annotations, and the compiler deduces every type:
double n = n * 2
firstNames users = List.map (\u -> u.firstName) users
The compiler knows double : Int -> Int (because * works on Int) and works out that firstNames takes a list of records that have a firstName field. You get full static checking with roughly the annotation burden of Python.
In practice, Mar programmers write type annotations on top-level functions anyway, as checked documentation:
double : Int -> Int
double n = n * 2
The annotation is for the reader; the compiler would have known. When your annotation and your code disagree, the compiler tells you which line is lying.
Records: shape, not class
A record is a value with named fields. A type alias names a record shape:
type alias Task =
{ id : Int
, name : String
, done : Bool
}
There is no constructor boilerplate, no getters, no inheritance. You build one with a literal, read fields with dots, and “modify” with update syntax (a new value, as always):
task = { id = 1, name = "Write the book", done = False }
task.name -- "Write the book"
{ task | done = True } -- a new Task
Unions: making the cases explicit
The star of the show. A union type (also called a custom type or sum type) says: a value of this type is one of these named cases, each optionally carrying data.
type Status
= Open
| InProgress
| Done
type Msg
= DraftChanged String
| AddClicked
| Added (Result Service.Error AddTaskOutcome)
Read Msg aloud: “a Msg is DraftChanged carrying a String, or AddClicked, or Added carrying a Result.” Unions are how Mar models anything that can be one of several things: UI events, API outcomes, game entities, navigation states.
The payoff comes from the marriage with case and its exhaustiveness check. When you match on a union, the compiler verifies every constructor is handled. Model your domain as unions and the compiler starts catching domain errors: the unhandled payment state, the forgotten enemy kind, the API outcome nobody renders. “Make impossible states unrepresentable” is the slogan; the practice is: instead of a record with three booleans that allow eight states of which five are nonsense, define a union with the three real states.
Maybe: the end of null
Mar has no null, no nil, no undefined. Absence is an ordinary union in the standard library:
type Maybe a
= Just a
| Nothing
A function that might not find a user returns Maybe User, and the type forces the caller to say what happens in both cases:
case Repo.findById users id of
Just user -> greet user
Nothing -> showNotFound
Compare with null: in most languages, every single reference is secretly a Maybe that the type system does not track, and the penalty for forgetting is a runtime crash. Mar flips the default. Values are always present unless the type says otherwise, and where it says otherwise, forgetting to handle Nothing is a compile error, not a production incident. The billion-dollar mistake, refunded.
Result: errors as values
The same move handles failure. No exceptions; a fallible operation returns:
type Result e a
= Ok a
| Err e
A service call delivers Result Service.Error AddTaskOutcome: either the outcome, or a typed reason. Because the error is in the return type, error handling is visible in the code and checked for coverage, instead of being an invisible alternate control flow that unwinds your stack from anywhere. The Error Model chapter builds Mar’s full error doctrine on this foundation.
Nominal IDs: cheap armor
A small pattern with outsized value. Wrap each entity’s id in its own one-case union:
type UserId = UserId Int
type PostId = PostId Int
UserId 7 and PostId 7 are now different types, and passing one where the other belongs is a compile error. The wrapper vanishes on the wire and in the database (Mar’s codecs encode it transparently); it exists purely to make an entire family of “joined the wrong table” bugs unwritable.
What the checker is really doing
Add it up. Exhaustive unions mean every case of your domain is handled. Maybe means every absence is handled. Result means every failure is handled. Inference means you paid almost nothing to get this. And because backend, frontend, and the contracts between them are all in one language, the same checker verifies the whole product: rename a field used across the stack and the compiler hands you the complete, finite list of places to fix.
That last property, types crossing the network, is where Mar stops being “a nice language” and starts being an architecture. Part II is about exactly that. One more foundation first: the strangest type decision in Mar, and the reasoning behind it.
Integers and Decimals
Mar has two number types, and neither of them is a float. Int is a whole number. Decimal is an exact base-10 number: 19.99 is really 19.99, not the closest 64-bit approximation. There is no Float and no Double anywhere in the language. Of all Mar’s decisions this is the one that raises eyebrows first, so it deserves a chapter: what goes wrong with floating point, how Mar’s two types divide the work, and why division has an unusual type.
The case against floating point
Floating-point numbers are a brilliant engineering compromise from the era of scarce memory: represent a huge range of magnitudes in 64 bits by storing a mantissa and an exponent, accepting that almost nothing is exact. Three consequences follow, and every working programmer has been bitten by at least one:
They lie about simple arithmetic. In float-land, 0.1 + 0.2 == 0.3 is false. Not a bug; the definition. 0.1 has no exact binary representation, so the language stores something near it, and errors accumulate with every operation. In Mar:
0.1 + 0.2 == 0.3 → True
Decimal literals are stored as exact base-10 values (a coefficient and a scale, like 1999 at scale 2 for 19.99), so arithmetic behaves the way you learned it in school.
They are poison for money. The classic production incident: prices as floats, a few million operations, cents drifting. Every serious finance codebase ends up banning floats and using integer cents or a decimal library. Mar makes the ban structural and ships the decimal library as the built-in type.
They break determinism across platforms. The same float expression can produce subtly different results on different hardware, math libraries, or optimization levels. Mar programs run on three runtimes (JavaScript, Swift, Go) that must agree exactly: a game replay, a rules engine evaluated on both server and client, a test recorded on one machine and run on another. Int and Decimal arithmetic are bit-for-bit identical everywhere, forever. With floats, “the same program” quietly becomes three programs.
Two types, two jobs
Int is for counting and for game math. Ticks, indices, pixel coordinates, scores, durations in milliseconds. Mar’s games run their physics in “px times 16”: one pixel is 16 units, giving 1/16-pixel precision with pure integers. A speed of 2.5 pixels per tick is stored as 40. Angles are degrees as integers; Canvas.Rotate takes them directly. Entire pseudo-3D racers, raycasting engines, and chip-tune scores are written this way, for the same reason games were written this way for decades: exactness and speed.
Decimal is for measuring. Money above all, but also quantities, rates, anything a human writes with a decimal point. 1.50 remembers it was written with two places (it prints back as 1.50, not 1.5), while == compares numerically, so 1.50 == 1.5 is True. Addition, subtraction, and multiplication are exact and closed: Decimal + Decimal is a Decimal with no rounding, ever.
The two do not mix silently. 1 + 1.5 is a type error; convert deliberately with Decimal.fromInt. Mixing is where unit bugs live, so the compiler makes you say what you mean.
Division names its precision
Division is where every numeric design shows its cards. 1 / 3 has no exact decimal answer, so something has to give: floats give you an approximation silently, and most decimal libraries pick a rounding rule for you. Mar refuses to round behind your back, and it splits division in two:
// is integer division. It truncates toward zero, and the doubled slash wears the loss in its spelling: 7 // 2 is 3, -7 // 2 is -3, and (so the same program cannot crash on one runtime and not another) n // 0 is 0. When order matters, multiply before dividing: a * 75 // 1000 keeps precision that a // 1000 * 75 throws away.
/ is Decimal division, and it returns a question, not a number. Its result is a Decimal.Division: the exact quotient held in suspense. You cannot print it, store it, or add to it. Exactly three functions resolve it, and each one makes you write down the precision:
-- Name the rounding and the scale:
1.0 / 3.0 |> Decimal.rounded Decimal.HalfEven 4 -- 0.3333
-- Or take the exact answer when one exists:
Decimal.exact (1.0 / 4.0) -- Just 0.25
Decimal.exact (1.0 / 3.0) -- Nothing
-- Or split without losing anything (q * b + r == a, guaranteed):
Decimal.withRemainder 2 (100.00 / Decimal.fromInt 3)
-- { quotient = 33.33, remainder = 0.01 }
That last one is the correct answer to a very old interview question: split $100 across three people and account for every cent. The rounding modes are the standard six (Decimal.HalfEven is banker’s rounding, plus HalfUp, Down, Up, Floor, Ceiling), and they appear only at the resolvers. There is no configuration flag, no ambient precision, no rounding you did not write at the call site. The lineage here is COBOL’s ROUNDED clause and Ada’s fixed-point types, the tools banks actually trusted, restated as a pipeline.
Money in practice looks like this:
formatMoney : Decimal -> String
formatMoney amount =
"$" ++ Decimal.toString (Decimal.toScale Decimal.HalfEven 2 amount)
total : List Decimal -> Decimal
total amounts =
List.foldl (\acc a -> acc + a) Decimal.zero amounts
The sum needs no rounding because + is exact; only display picks a scale. On the database side, Entity.decimal 2 declares a money column: SQLite stores the integer coefficient (literally cents), reads come back as Decimal, and a write with more than two places aborts instead of rounding silently.
What you gain back
- Arithmetic you can reason about with grade-school rules.
a + b - b == a, always, in both types. - Equality that means equality. No epsilon comparisons, no “close enough” helpers.
- One behavior on web, iOS, and server. The shared rules engine in Mar’s card game example is replayed independently by the Go backend and the JS client, and must land on identical states. Exact numbers make that a non-event.
- Serialization without surprises. Decimals travel as strings on the wire, so no JSON parser anywhere gets a chance to turn
0.1into0.30000000000000004downstream.
The honest costs
Some domains genuinely want approximate math with large dynamic range: scientific computing, signal processing, 3D transforms with arbitrary rotation. Mar is a poor fit for those today, and pretending otherwise would be silly. Decimal is capped at 34 significant digits, and a computation that overflows it errors rather than losing digits quietly. And the division pipeline costs a few more keystrokes than / in other languages; that is the price of never being surprised by a rounding you did not choose.
The design instinct to take away
Mar consistently prefers removing a footgun over documenting it. Null went; exceptions went; mutation went; floats went; silent rounding went. Each removal traded a familiar convenience for a guarantee the compiler can enforce. If that trade offends you, Mar will keep offending you. If it appeals, you now have the complete mental toolkit of Part I, and it is time to build something: a user interface, out of a loop, a value, and two pure functions.
Model, View, Update
Part I established the raw material: immutable values, pure functions, expressions, and types. This part assembles them into a user interface.
The architecture is called MVU, for Model-View-Update, and it originated in the Elm community (you will also hear “The Elm Architecture”). It is not one option among many in Mar; it is the way frontends are written. Every page in every Mar app, and every 60fps game, is the same three pieces:
- a Model: one immutable value holding all the state of the page,
- a view function: Model in, description of the screen out,
- an update function: message plus old Model in, new Model (plus commands) out.
If you have used React with a reducer (Redux, useReducer), you have met MVU’s grandchild; the pattern crossed over from Elm. The original, as usual, is simpler than the copies: there are no hooks, no effects-in-components, no context, no memoization decisions, because the language guarantees make them unnecessary.
The chapters:
- The Loop: the full circle, from Model to pixels to input and back, and why one-way data flow ends a whole genre of UI bugs.
- Commands: how a pure
updatetalks to servers and clocks. - Subscriptions: how the world talks back on its own schedule: timers, keyboards, game ticks.
- Pages and Navigation: how Models become an application with routes, protected pages, and typed links.
The Loop
Every interactive program answers three questions: what is the state, how is it shown, and how does it change? MVU answers each with one piece, and connects them in a single one-way loop.
A complete page
Here is a counter, whole. Every Mar page you ever write is this shape, scaled up:
module Frontend.Counter exposing (page)
import UI exposing (navigationStack, navigationTitle, section, list, title, button)
type alias Model =
{ count : Int }
type Msg
= Increment
| Decrement
init : (Model, Cmd Msg)
init =
( { count = 0 }, Cmd.none )
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
Increment ->
( { model | count = model.count + 1 }, Cmd.none )
Decrement ->
( { model | count = model.count - 1 }, Cmd.none )
view : Model -> View Msg
view model =
navigationStack [ navigationTitle "Counter" ]
[ list []
[ section []
[ title (String.fromInt model.count)
, button [] Increment "+1"
, button [] Decrement "-1"
]
]
]
page : Page
page =
Page.create
{ path = "/"
, title = "Counter"
, init = init
, update = update
, view = view
, subscriptions = always Sub.none
}
Walk the pieces:
- Model is all the state of this page, as one immutable record. Not state scattered across components; one value you could print.
- Msg is a union of everything that can happen. Two things can happen to a counter. A real page might have fifteen. The type is a complete, compiler-checked inventory of the page’s events.
- init is the starting state (plus any commands to run immediately, such as fetching data).
- update is the entire behavior of the page: given a thing that happened and the current state, produce the next state. Pure, exhaustive, no exceptions possible.
- view is the entire appearance of the page: given the state, describe the screen. Also pure: same Model, same screen, every time.
- page packages them with a route;
subscriptionswaits until its own chapter.
How the runtime drives it
You never call these functions. The runtime does, in a loop:
- It holds the current Model (the one mutable cell in the universe, owned by the runtime, invisible to you).
- It calls
view modeland renders the description (as DOM on the web, SwiftUI on iOS). - The user taps “+1”. The runtime does not run your handler in place; it constructs the message
Incrementand queues it. - It calls
update Increment model, gets a new Model, swaps it in. - It calls
viewagain with the new Model and reconciles the screen to match (efficiently, diffing descriptions; you never touch the DOM). - Repeat forever.
Everything flows one way around the circle: state to screen to input to message to state. There are no other paths. The screen cannot write to the state; input cannot mutate the view; nothing happens between frames that update did not decide.
Why one-way flow matters
Interactive UI was traditionally written as a web of observers: this input’s change handler updates that label, unless the other checkbox is on, in which case… Each widget holds a shard of state, and consistency between shards is maintained by hand, by remembering every path. The bug report reads “if you click these in the wrong order, the totals disagree”.
MVU makes disagreement impossible by construction. There is one state, and every pixel is a function of it. Two parts of the screen cannot contradict each other, because neither owns anything: both are derived, every frame, from the same Model. “Sync the UI” stops being your job; it is the loop.
The other gift is explainability. The Model at any moment equals init folded through the exact sequence of messages so far. That sequence is finite, typed, and loggable. Mar’s dev tools record it, which is why the time-travel debugger can replay your session: your app is, mathematically, a left fold over its events. (A fancier way to say it: MVU makes event sourcing the substrate of the UI.)
“Where are the components?”
Newcomers from React or Vue ask this within the hour. The honest answer: there are none, and it is on purpose. Reusable view logic is just functions (a taskRow : Task -> View Msg used by three pages is an ordinary function). Reusable state logic mostly dissolves once state is one value and behavior is one function; where it persists, you extract helper functions over Model and Msg.
What you do not get is a tree of stateful components, each with private lifecycles to coordinate. That tree is precisely the thing MVU exists to not have. A page is the unit of state; below it, everything is a pure function.
The counter never talks to a server, though, and real pages do. That requires the second half of update’s return value, and the next chapter.
Commands: Effects as Data
The counter’s update returned ( newModel, Cmd.none ) and we ignored the second slot. That slot is how a pure function causes things to happen in the world. This chapter makes the frontend’s effect story concrete; it is the “effects are values” principle from Part I, wearing its work clothes.
The contract
update : Msg -> Model -> (Model, Cmd Msg)
update answers two questions at once: what is the new state and what should the runtime do next. A Cmd Msg is a description of work whose eventual outcome will come back as, precisely, another Msg. The circle stays closed: effects do not interrupt the loop, they travel around it.
The vocabulary is small:
Cmd.none : Cmd msg -- nothing to do
Cmd.batch : List (Cmd msg) -> Cmd msg -- several at once
Cmd.perform : (a -> msg) -> Task a -> Cmd msg -- run a Task, box the result as a Msg
Service.call : Service req resp
-> req
-> (Result Service.Error resp -> msg)
-> Cmd msg -- call the backend
Nav.pushTo : String -> Cmd msg -- navigate
The fetch, in full
Here is the canonical shape: a page that loads its data on entry.
type alias Model =
{ tasks : List Shared.Task
, loading : Bool
}
type Msg
= Fetched (Result Service.Error (List Shared.Task))
init : (Model, Cmd Msg)
init =
( { tasks = [], loading = True }
, Service.call Shared.listTasks () Fetched
)
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
Fetched (Ok tasks) ->
( { model | tasks = tasks, loading = False }, Cmd.none )
Fetched (Err why) ->
( { model | loading = False }, Cmd.none )
Read init closely, because the timing is the whole lesson. It returns instantly with an empty, loading Model and a command. No data has been fetched when init returns; the page renders its loading state first. The runtime then performs the request, and some milliseconds later update receives Fetched (Ok ...) like any other event. Loading is not a special mode bolted onto the framework; it is simply a Model your view knows how to draw.
Notice also what the compiler is holding for you:
Fetchedcarries aResult, so the failure branch must exist or thecaseis inexhaustive. You cannot forget the error path.- The response type comes from the
Shared.listTaskscontract. If the backend changes what it returns, this page stops compiling.
Why update cannot “just fetch”
The question everyone asks: why the detour? Why can’t update call the server and use the response right there?
Because then update would not be a function anymore. Its result would depend on the network, the server’s mood, and the time of day. Every guarantee from Part I unravels at once: testing needs mocks, replay becomes impossible, the time-travel debugger lies, and “what happened?” is no longer answerable from the message log.
The Cmd detour keeps the boundary crisp. The decision to fetch is pure and lives in your code. The fetching is impure and lives in the runtime. The result re-enters as data. You can hold the entire behavior of a page in your head, or in a test, by listing its messages, and each round through the loop is one atomic step: message in, state plus intentions out.
There is a subtler payoff. Because effects are inert values, update composes them freely: return them from case branches, build them conditionally, batch them. “Save, then navigate, then refresh the list” is not callback choreography; it is a value:
Saved (Ok note) ->
( { model | saving = False }
, Cmd.batch
[ Nav.replaceTo (Frontend.Routes.noteDetail note.id)
, Service.call Shared.listNotes () Refreshed
]
)
Tasks reach the frontend through Cmd.perform
Some effects produce a plain value rather than a service response; the clock is the everyday example. Those are Tasks (the backend chapter covers them fully), and Cmd.perform is the bridge:
init =
( { today = Nothing }
, Cmd.perform GotToday Time.now
)
“Run Time.now; deliver its value wrapped in GotToday.” The pattern is always the same: the Model holds a Maybe Time, renders a placeholder until the message lands, then renders the real thing. One rule of thumb falls out: a Mar frontend never blocks. Anything that takes time arrives as a message when it is ready, and the Model always has a face to show meanwhile.
The discipline, stated once
A page’s behavior is fully specified by three declarations: what can happen (Msg), how state responds (update), and what the world is asked to do (Cmd values). Nothing else moves. When a bug appears, there are exactly three questions, asked in order: did the right message fire, did update map it to the right state, did the view draw that state correctly. Twenty years of “who mutated what, when, from which callback” collapses into that checklist.
Commands cover effects you initiate. The world also volunteers information on its own schedule: clocks tick, keys go down. That is subscriptions.
Subscriptions: Listening to the World
Commands are outbound: your code asks, the runtime does. But some information is not asked for; it streams. The clock ticks whether or not anyone fetched it. Keys go down when the player decides, not when your code polls. Subscriptions are MVU’s inbound channel: standing declarations of interest that turn outside events into messages.
Declared, not registered
Every page carries a subscriptions field: a pure function from the current Model to what the page wants to hear about.
subscriptions : Model -> Sub Msg
subscriptions model =
if model.running then
Time.every (Time.millis 40) Tick
else
Sub.none
Read it as a sentence: “while the model says we are running, deliver a Tick every 40 milliseconds; otherwise, nothing.” The runtime calls this function after every update and reconciles reality to it, starting timers that should exist and stopping ones that no longer should.
Compare this to addEventListener and setInterval. Imperative listeners are registrations with a lifetime you manage: attach on mount, detach on unmount, remember the handle, do not double-attach, do not leak on that one early-exit path. The genre of bug is famous (the interval that keeps firing on a page the user left three screens ago). A Mar subscription has no lifetime to manage because it is not a registration at all; it is a description of the desired present, recomputed from state. Pause the game by setting running = False in the Model, and the timer stops, because the description now says Sub.none. There is nothing to clean up; there never was a resource in your hands.
The building blocks compose like everything else:
Sub.none : Sub msg
Sub.batch : List (Sub msg) -> Sub msg
The standard sources
Time.every interval msg: the metronome. Everything periodic, from a clock widget to a 60fps simulation, is this.Keyboard.onKeyDown/Keyboard.onKeyUp: each delivers aKeyboard.Key(a union:Keyboard.KeyW,Keyboard.ArrowUp,Keyboard.Space, …) wrapped in your message. Note the shape: keys held down are not a subscription concern; you record down and up in your Model and consult it during ticks, which is exactly how the games do it.Gamepad.*: mirrors the keyboard, for controllers.Device.watch: delivers capability changes (can this device hover, is it touch-only), so a page can adapt its controls when an iPad user connects a trackpad. Capabilities, not user-agent sniffing.
Ticks are how games happen
A detail with big consequences: a game in Mar is not a special program with a render loop. It is an ordinary MVU page whose subscriptions asks for a fast Tick, whose update advances a simulation one step per tick, and whose view draws the Model onto a canvas.
subscriptions model =
Sub.batch
[ Time.every (Time.millis 16) Tick
, Keyboard.onKeyDown KeyDown
, Keyboard.onKeyUp KeyUp
]
The runtime aligns these fast timers with the display (and, when a device falls behind, runs catch-up steps so game speed stays true to wall-clock time). But architecturally nothing new happened: state is still one value, ticks are still messages, update is still pure. That purity is why a Mar game gets save-anywhere, replay, and time-scrubbing debugging for free: a game state is just a Model, and a session is just its message list.
The complete frontend contract
With subscriptions in place, the frontend picture is finished, and it is pleasingly small. A page is five declarations:
| Piece | Type | Role |
|---|---|---|
Model | your record | all state |
Msg | your union | all events |
init | (Model, Cmd Msg) | starting state and effects |
update | Msg -> Model -> (Model, Cmd Msg) | all behavior |
view | Model -> View Msg | all appearance |
subscriptions | Model -> Sub Msg | all standing interests |
Six, counting the routing record that binds them. There is no seventh thing. Every Mar frontend you will ever read, from a sign-in form to a real-time strategy game, is an instance of this table, and once that clicks, unfamiliar codebases stop being unfamiliar.
Next: how pages assemble into an application, and how navigation and sessions fit the same mold.
Pages and Navigation
An application is more than one screen. Mar’s unit of composition is the page: one route, one MVU loop, one module. This chapter covers how pages declare their routes, how typed parameters and protected pages work, and how navigation stays inside the architecture.
One page, one loop, one file
Each page module exports a page value built by a Page.* combinator, packaging the five MVU pieces with a path and a title:
page : Page
page =
Page.create
{ path = "/"
, title = "Team Notes"
, init = init
, update = update
, view = view
, subscriptions = always Sub.none
}
Main.mar lists every page (and every service) explicitly; the runtime dispatches by path:
main : Cmd ()
main =
App.fullstack
{ services = Backend.Notes.services
, pages =
[ Frontend.SignIn.page
, Frontend.Home.page
, Frontend.NoteDetail.page
]
}
No auto-discovery, no filesystem routing conventions to memorize: the list is the app, and a page you forgot to register is a page that visibly is not there.
Pages are deliberately independent. Each has its own Model; navigating instantiates the new page’s init and discards the old page’s state. (Navigate back and init runs again, refetching. Data that must outlive a page lives on the server. A client-side shared store is on Mar’s roadmap; the current model is the simple one.)
The combinators encode the page’s contract
The choice of combinator states, in the type system, what a page needs before it can exist:
Page.create -- public, static path; init : (Model, Cmd Msg)
Page.protected -- needs a session; init : User -> (Model, Cmd Msg)
Page.dynamic -- path carries args; init : args -> (Model, Cmd Msg)
Page.dynamicProtected -- both; init : User -> args -> (Model, Cmd Msg)
Look at what Page.protected does to init’s type: the page cannot be initialized without a User. This is the “make impossible states unrepresentable” doctrine applied to auth. There is no “check if logged in” conditional to forget, no redirect middleware to misconfigure per route. A protected page’s very init demands a proof of session, and the runtime supplies it (or redirects to sign-in) before your code runs.
Typed routes
Dynamic pages carry parameters in the path, declared with typed placeholders and kept in a Routes module both link and page import:
-- Frontend/Routes.mar
home = "/"
noteDetail = "/notes/{id:Int}"
-- Frontend/NoteDetail.mar
page =
Page.dynamic
{ path = Frontend.Routes.noteDetail
, ...
}
init : { id : Int } -> (Model, Cmd Msg)
init args =
( { note = Nothing }
, Service.call Shared.getNote { id = args.id } Fetched
)
The placeholder {id:Int} is parsed, converted, and delivered to init as a record field, already an Int. A URL with garbage where the id belongs never reaches your code. And because links and pages share the one declaration, a route rename is a compile-time event, not a 404 discovered by users.
Navigating
Two ways to move, both inside the loop:
- In a view:
navigationLinkrenders a tappable row that pushes its destination. Declarative, no handler needed. - In
update: navigation is aCmd, like any other effect.
Saved (Ok note) ->
( model, Nav.replaceTo (Frontend.Routes.noteDetail note.id) )
Nav.pushTo adds a history entry (Back returns here); Nav.replaceTo swaps the current one (Back skips it; right after “create”, you rarely want Back to resurrect the blank form). After sign-in, Auth.completeSignIn returns the user to wherever a 401 interrupted them.
Note what navigation is not: it is not a function you call for its side effect mid-computation. It is a value update returns, which means it obeys the same reasoning as everything else. A test can assert “this message leads to this navigation” by inspecting the returned command, no browser required.
The app, so far
You now have the entire frontend half of Mar: pure state machines per page, effects as commands, events as subscriptions, and routes as types. What is missing is the other half of every real page: Shared.listTasks and friends, the services those Service.calls have been invoking on faith. Part II’s second act crosses the wire.
One Language, Whole App
Part I gave the discipline, and the MVU chapters gave the frontend. This chapter group crosses the network: database, server, contracts, errors, and authentication, all in the same language, all checked by the same compiler.
The claim to keep in view: in a conventional stack, the seams between frontend and backend are where correctness goes to die, because no tool can see both sides at once. Mar’s whole bet is that if one compiler sees the entire application, the seams become checkable, and most of the scaffolding every product rebuilds (auth, migrations, API plumbing, admin) can ship in the box, typed.
The pieces, in reading order:
- Services: a typed contract, declared once, that both halves import. The end of API drift.
- Entities and the Repo: the database as typed declarations; migrations derived, not written.
- Tasks: the backend’s effect type, and how handlers chain reads and writes while staying declarative.
- The Error Model: three kinds of failure, three homes, zero exceptions.
- Auth in the Box: passwordless sign-in as a framework feature, and why auth is the scaffolding most worth standardizing.
Services: The Contract Is the Code
Every client-server app has an API contract. The question is where it lives. In most stacks it lives in three places at once: the backend’s route handlers, the frontend’s fetch calls, and (if the team is diligent) a schema document that was accurate in March. Each copy can drift, and the drift is invisible until runtime.
Mar’s answer: the contract is one value, in one module, imported by both sides.
Declaring a contract
-- Shared.mar
type alias Task = { id : Int, name : String, done : Bool }
type AddTaskOutcome
= Added Task
| NameEmpty
listTasks : Service () (List Task)
listTasks = Service.declare GET "/tasks"
getTask : Service { id : Int } (Maybe Task)
getTask = Service.declare GET "/tasks/{id:Int}"
addTask : Service { name : String } AddTaskOutcome
addTask = Service.declare POST "/tasks"
A Service req resp is a typed promise: send req, receive resp. Service.declare pins the HTTP verb and path; the type annotation pins the payload types. That is the entire API layer. No OpenAPI file, no generated client, no serializers: Mar derives the wire encoding from the types (path params fill their {name:Type} slots; the rest rides the query string for GET/DELETE and the JSON body for POST/PUT/PATCH).
Implementing it
The backend pairs each contract with a handler whose type the contract dictates:
-- Backend/Tasks.mar
addTaskImpl : { name : String } -> Shared.User -> Task Shared.AddTaskOutcome
addTaskImpl input user =
if String.trim input.name == "" then
Task.succeed Shared.NameEmpty
else
Repo.create tasks
{ name = input.name, done = False, userId = user.id }
|> Task.map Shared.Added
services =
[ Auth.protect Shared.listTasks listTasksImpl
, Auth.protect Shared.addTask addTaskImpl
]
A handler is req -> Task resp (Auth.protect inserts the signed-in User as a second argument; the next chapters cover Task and auth). The part to appreciate: the handler cannot return anything but the declared response type. There is no way to sneak an undocumented field or a surprise shape into the API, because the API is a type and the handler is checked against it.
Calling it
-- Frontend page, in update
AddClicked ->
( { model | submitting = True }
, Service.call Shared.addTask { name = model.draft } Added
)
Added (Ok (Shared.Added task)) -> ...
Added (Ok Shared.NameEmpty) -> ...
Added (Err why) -> ...
Service.call takes the contract, a request that must typecheck against it, and the message to deliver the result. HTTP, encoding, decoding: invisible. The page speaks in domain types.
What the compiler now guarantees
Because both halves import the same Shared.addTask:
- Rename the service, change a field, or alter the outcome type, and every call site and the handler fail to compile until updated. API drift is not detected; it is impossible.
- The frontend’s
caseoverAddTaskOutcomeis exhaustiveness-checked. Add aTeamFulloutcome next month and every page that callsaddTaskbecomes a todo list of compile errors, which is exactly what you want. - Requests are well-formed by construction. There is no way to forget a required field in the payload; the record type will not have it.
One more check, unusual and telling: a GET service whose handler writes is a compile error. Effects are visible in types (that was Part I’s promise), so the compiler can see a Repo.update reached from a GET handler and reject it. HTTP semantics, enforced statically.
The philosophical point
Notice what did not happen here. There is no code generation step, no shared-types package to version and publish, no JSON schema to keep honest. Those are all tools for coordinating two programs written in two languages. Mar dissolves the problem instead of managing it: there is one program, and the “API” is just its module boundary.
This is the sashimono idea at full strength. The joint holds because the two pieces are shaped against each other by the same tool, not because glue was applied afterward.
Next: where the data those handlers touch actually lives.
Entities and the Repo
Databases are where type systems traditionally give up. The schema lives in SQL, the code’s model of it lives in classes or structs, and an ORM performs diplomacy between them. When they disagree, you find out at query time. Mar closes this gap the same way it closed the API gap: one declaration, in the language, from which everything else is derived.
An entity is a typed table
-- Backend/Tasks.mar
type alias Task =
{ id : Int
, name : String
, done : Bool
, createdAt : Time
, userId : Int
}
tasks : Entity Task
tasks =
Entity.define
{ name = "tasks"
, columns =
{ id = Entity.serial
, name = Entity.text Entity.notNull
, done = Entity.bool Entity.notNull
, createdAt = Entity.timestamp Entity.notNull
, userId = Entity.int Entity.notNull
}
, uniques = []
}
The record alias is the row type your whole app uses; Entity.define maps it onto a table. The columns record must mirror the record’s fields, and the compiler checks that it does. An entity carries schema only: no methods, no business logic, no lifecycle hooks. Behavior lives in handlers, where it can be read.
The column vocabulary follows the type vocabulary: Entity.serial (auto-increment key), Entity.int, Entity.text, Entity.bool, Entity.timestamp (a Time, stored as Unix milliseconds), Entity.enum (a closed union, CHECK-constrained), and Entity.decimal scale (an exact Decimal with a fixed number of places; Entity.decimal 2 stores money as integer cents underneath, and a write with more places than the column aborts instead of rounding silently).
uniques declares unique indexes as lists of column groups ([["commentId", "userId", "emoji"]] reads “one reaction per user per comment”), turning a business rule into a database-enforced fact.
Migrations you do not write
Here is the feature people miss most when they leave: there are no migration files. On startup, the runtime compares your entity declarations to the actual SQLite schema:
- Additive changes (new table, new column, new index) apply automatically, logged with timing.
- Destructive changes (drop a column, change a type) refuse to start with a clear error and a suggested manual step. Mar will not silently eat data to make the schema match.
The workflow is: edit the record, edit the columns to match (the compiler holds your hand), restart mar dev. The migration is a consequence of the type, in the same way the JSON encoding was a consequence of the type. Ordinary schema evolution stops being an artifact you author and review; it becomes a diff the runtime derives.
Underneath sits SQLite, embedded in the server binary: zero-configuration, a single file, absurdly reliable, and comfortably sufficient for the team-sized apps Mar targets. (It is also a real limit for some products; the trade-offs chapter treats this honestly.)
The Repo: reading and writing rows
Data access is one small module, and every operation returns a Task (the backend effect type, next chapter):
Repo.all : Entity a -> Task (List a)
Repo.findById : Entity a -> Int -> Task (Maybe a)
Repo.findBy : Entity a -> fields -> Task (List a)
Repo.create : Entity a -> fields -> Task a
Repo.update : Entity a -> Int -> fields -> Task (Maybe a)
Repo.deleteById : Entity a -> Int -> Task ()
The types encode the semantics you usually learn from documentation, or incidents:
findByIdreturnsMaybe a. The row might not exist, so the type says so, and the caller must handleNothing. No “record not found” exception.createtakes the row minus the serial id (the runtime fills it). You cannot supply a bogus id, because the record type for the argument does not have the field.updatetakes just the columns to change and returnsMaybe a:Nothingtold you the id was stale, in the return type, not in a thrown surprise.findByfilters by example:Repo.findBy tasks { userId = user.id }returns the rows whose columns equal the record you pass.
listTasksImpl : () -> Shared.User -> Task (List Shared.Task)
listTasksImpl _ user =
Repo.findBy tasks { userId = user.id }
|> Task.map (List.sortWith byPosition)
Where are the joins?
Deliberately absent, for now. There is no query builder, no relations DSL, no raw SQL escape hatch in app code. You read rows with the six operations and compose the rest with ordinary Mar: List.filter, List.map, Task.andThen across entities.
This is a real constraint, and it is worth understanding the reasoning rather than just the rule. Query DSLs are where ORMs become haunted: the innocent-looking property access that fires N+1 queries, the lazy relation that explodes outside its session, the string of chained methods that generates SQL nobody predicted. Mar’s Repo is small enough that every data access is explicit, predictable, and visible in the handler that performs it. For the target class of app, most “joins” are a findBy plus an in-memory pass over a few hundred rows, and SQLite makes those reads very cheap.
When a real need outgrows this, the language will grow a typed answer (the same way Decimal became the answer for money columns). What it will not grow is a string-typed side door.
The register, one more time
An entity declaration is a single source from which Mar derives: the table, its migrations, the row type, the JSON encoding at the API boundary, and the admin panel’s browsing UI at /_mar/admin. Five artifacts that in a conventional stack are five files in four languages, drifting. Here they are projections of one declaration, and the compiler owns the projections.
Handlers, meanwhile, have been returning these Task values everywhere. Time to look at what a Task actually is.
Tasks: Effects on the Server
The frontend describes its effects with Cmd; the backend describes its own with Task. Same philosophy, different shape, and the difference is instructive: a Cmd fires work whose result comes back later, as a message, because a UI is an event loop. A backend handler instead computes one response per request, so its effect type is a value pipeline: a Task a is “a computation that, when run, produces an a”.
The shape of a handler
A handler’s job: given a request, produce a Task of the response. The runtime runs it.
toggleImpl : { id : Int } -> Shared.User -> Task (Maybe Shared.Task)
toggleImpl input user =
let
found <- Repo.findById tasks input.id
in
case found of
Just task ->
if task.userId == user.id then
Repo.update tasks input.id { done = task.done == False }
else
Task.succeed Nothing
Nothing ->
Task.succeed Nothing
The new syntax is let ... <-, pronounced “bind”: run the Task on the right, name its result, continue. It reads like straight-line code and is pure sugar for Task.andThen, the function that chains one Task’s output into the next Task. If you know async/await, the analogy is nearly exact: <- is await, Task a is Promise<a>, and the handler is an async function. The difference, as ever, is that a Task is an inert description: building one does nothing, running it is the runtime’s job, and your function stays a pure arrangement of steps.
The combinators
Task.succeed : a -> Task a -- a finished Task
Task.fail : String -> Task a -- abort the handler
Task.map : (a -> b) -> Task a -> Task b -- transform the result
Task.andThen : (a -> Task b) -> Task a -> Task b -- chain (what <- sugars)
Task.sequence : List (Task a) -> Task (List a) -- run a list of Tasks
Task.forEach : (a -> Task ()) -> List a -> Task () -- act on each element
Two everyday idioms. Transforming a read without ceremony:
Repo.all tasks |> Task.map (List.filter (\t -> t.done))
And branching into effects: because if and case are expressions, choosing which effect to run is ordinary code, no builder pattern required:
if String.trim input.name == "" then
Task.succeed Shared.NameEmpty
else
Repo.create tasks { name = input.name, done = False, userId = user.id }
|> Task.map Shared.Added
Sources of Tasks are exactly the things a server legitimately touches: the Repo.* operations, Time.now : Task Time, and Task.succeed/Task.fail. Notably absent: arbitrary HTTP calls, file IO, shell commands. The surface is small on purpose; every capability added to Task is a capability every reviewer must henceforth assume any handler might use.
Task.fail is an abort, not an error channel
Task.fail "message" stops the handler; the client receives the message as a generic server error. Its role is broken invariants: states that indicate a bug, not an outcome. “The row this session points to has vanished” is a Task.fail. “The name was empty” is not: that is a domain outcome the frontend must react to, so it belongs in the response type (NameEmpty above), where the exhaustiveness checker will make every calling page handle it.
Keep that split in mind; the next chapter builds Mar’s whole error doctrine on it. The one-sentence preview: if a page needs to branch on it, it is data; only if nobody can react to it is it an abort.
Time, and other honest effects
Why is even Time.now a Task, when reading a clock seems so harmless? Because “harmless” reads are precisely how nondeterminism seeps into systems. A handler that consults the clock in the middle of logic is a handler whose behavior differs at midnight. Making it a Task pins the read to an explicit, visible step:
addTaskImpl input user =
let
now <- Time.now
in
Repo.create tasks
{ name = input.name, done = False, createdAt = now, userId = user.id }
The value now is then an ordinary immutable value; the rest of the computation is deterministic given it. Computed-at-creation fields (timestamps, slugs) follow this pattern: read once, pass the value, keep the entity layer dumb.
The symmetry, completed
One mental table to carry forward:
| Frontend | Backend | |
|---|---|---|
| Effect type | Cmd msg | Task a |
| Meaning | “do this; message me the result” | “a step that produces a value” |
| Composition | Cmd.batch, messages through update | Task.map, Task.andThen, let <- |
| Who runs it | the MVU runtime | the request runtime |
| Bridge | Cmd.perform turns a Task into a Cmd |
They were once a single unified type, and Mar split them after real-world use showed the merge let a category of bug compile (a value-producing effect returned where a message was expected, silently doing nothing). It is a nice miniature of the language’s design process: unify when it clarifies, split when the type checker can catch more by telling things apart.
So far the happy path. Failure deserves its own chapter.
The Error Model
Exceptions are the last place most languages allow spooky action at a distance. Any call might throw; the handler is some unknown number of frames up; the type signature says nothing. Mar has no exceptions, so it owes you an answer: where do failures go?
The answer is a taxonomy. Three kinds of failure, three homes, each chosen so that the type system forces exactly the right people to care.
1. Transport failures: the shared union
Some failures can strike any service call, because networks and sessions are mortal: the request never left the device, the session expired, the server fell over. Every Service.call therefore delivers Result Service.Error resp, where the error side is one shared union:
type Service.Error
= Offline -- the request never reached the server
| Unauthorized -- session gone (the server said 401)
| ServerError String -- the server refused; carries its message
Your page’s case on the result must have an Err branch (exhaustiveness again), and can be as coarse or fine as the UX deserves:
Fetched (Err Service.Offline) ->
( { model | banner = Just "You appear to be offline. Retry?" }, Cmd.none )
Fetched (Err why) ->
( { model | banner = Just (Service.errorToString why) }, Cmd.none )
The insight worth stealing even outside Mar: transport failure is not part of any endpoint’s domain, so it should never appear in endpoint response types, and no endpoint should be able to forget it. One union, delivered uniformly by the runtime, achieves both.
2. Domain outcomes: the response type
“Email already taken.” “Wrong code.” “Name empty.” These are not failures of infrastructure; they are legitimate answers to a request, and each service has its own. So they live where answers live: in the service’s declared response, as a union of what can actually happen:
type SignupOutcome
= Created User
| EmailTaken
| TeamFull
signup : Service NewUser SignupOutcome
signup = Service.declare POST "/signup"
Now follow the consequences around the loop. The handler is NewUser -> Task SignupOutcome: it cannot produce an undeclared outcome. The frontend matches:
Done (Ok (Created user)) -> ...
Done (Ok EmailTaken) -> ...
Done (Ok TeamFull) -> ...
Done (Err why) -> ...
Every outcome handled, or no compile. When the product grows a fourth outcome, the compiler distributes the work: every page that calls signup lights up until it says what the new case looks like on screen. Compare the string-matching alternative (if err.message === "EMAIL_TAKEN"), which drifts silently the day someone reworks the message.
The design rule: never a shared catch-all for domain errors. A page should only have to match what can actually happen to it. A global AppError union with forty constructors makes every caller handle thirty-seven impossibilities, which trains programmers to write the catch-all arm that defeats the whole system.
3. Broken invariants: the abort
Some states should be impossible: the row a valid session points to has vanished; a computation produced garbage. These are bugs, not outcomes; no page has a sensible branch for them. Task.fail "session user missing" aborts the handler, and the client sees it as ServerError with the message, display-only.
The test for which home a failure belongs in is behavioral, and worth memorizing:
If a page would branch on it, it is a domain outcome. If all a page can do is apologize, it is transport or an abort.
Words belong to the view
Notice that nothing on the wire is human language. Constructors cross the network; sentences do not. EmailTaken reaches the frontend as a value, and each frontend (web, iOS, a future localization) chooses its own words. Error copy is a rendering concern, so it lives in view with the rest of the rendering. Backends that send English sentences to be shown verbatim have quietly made the server responsible for UX and made translation impossible; Mar’s split keeps each side owning what it can actually do well.
The model in one table
| Failure | Example | Where it lives | Who must handle it |
|---|---|---|---|
| Transport | offline, 401, crash | Service.Error, in every call’s Err | every page, via exhaustiveness |
| Domain | email taken, wrong code | the service’s response union | pages calling that service |
| Invariant | “impossible” state | Task.fail, surfaces as ServerError | nobody branches; it is displayed and logged |
Three homes, no exceptions, no invisible control flow, and the compiler enforcing that the people who can react do. The framework’s own features follow the same pattern, which brings us to the largest of them.
Auth in the Box
Authentication is the scaffolding every product rebuilds, and the one where rebuilding is most dangerous: session fixation, timing attacks, rate limits, password storage. Mar’s position is that auth belongs to the framework, the way migrations do, and that the default flow should have no passwords at all.
Passwordless, by default
The built-in flow: the user types an email, receives a six-digit one-time code, types it, and has a session. No password to store, hash, leak, reuse, or reset. For the small and mid-sized products Mar targets, this is both more secure than a homegrown password table and dramatically less code.
The app supplies its own User entity and registers it once:
auth : Auth { id : Int, email : String }
auth =
Auth.config
{ entity = Backend.Users.users
, identify = \u -> u.email
, signInPage = Frontend.SignIn.page
, email = { subject = "Your sign-in code" }
, signup = \userEmail -> { email = userEmail }
, sessionDuration = Time.days 30
}
Read the fields as the answers to auth’s eternal questions. Which table holds users? How do I find one from an email (identify)? Where do I send someone with no session (signInPage)? What does a brand-new user’s row look like (signup, so first sign-in doubles as registration)? How long do sessions live?
The runtime supplies the rest: code generation and expiry, the email send (SMTP config lives in mar.json, secrets via env:), session issuance, cookie handling on the web and secure token storage on iOS, and rate limiting on both the email and the guessing side.
Protecting things
You met both halves already; here is the pattern named. Services are protected by wrapping:
services =
[ Auth.protect Shared.listTasks listTasksImpl ]
Auth.protect rejects the call with 401 before your handler runs, and otherwise hands the handler the signed-in User as an argument. The handler’s type requires that argument, so an unauthenticated path into protected logic does not exist in the compiled program. Authorization then starts from a trustworthy identity: Repo.findBy tasks { userId = user.id } scopes data to the caller because the caller is a fact, not a header you parsed yourself.
Pages are protected by the combinator: Page.protected runs the session check on entry, redirects to signInPage when it fails, and passes the User into init when it succeeds.
The sign-in page itself is ordinary MVU calling two framework services, whose outcomes are typed unions like every other outcome in the language:
-- Auth.requestCode delivers Auth.RequestOutcome:
-- Auth.CodeSent | Auth.InvalidEmail | Auth.RateLimited
-- Auth.verifyCode delivers Auth.VerifyOutcome user:
-- Auth.SignedIn user | Auth.WrongCode | Auth.TooManyAttempts
Verified (Ok (Auth.SignedIn user)) ->
( model, Auth.completeSignIn )
Verified (Ok Auth.WrongCode) ->
( { model | error = Just "That code did not match. Try again." }, Cmd.none )
Auth.completeSignIn returns the user to wherever a 401 originally interrupted them. Note the doctrine holding: WrongCode is a domain outcome a page renders, not an exception, not a status code you compare against 403 and hope.
Why this belongs in the framework
An application-level auth library can never promise what a language-level one can. Because Auth.protect and Page.protected are the only doors, and both demand the User in the types behind them, the classic failure (the one route where someone forgot the middleware) is not a lurking possibility that audits hunt for; it is unrepresentable. The session store, the rate limiter, and the cookie flags are the framework’s responsibility, tested once, hardened once, shared by every Mar app.
There is also a subtler benefit: uniformity across platforms. The same Auth.config drives the web app and the iOS app; the runtime handles each platform’s session storage idioms. You did not write auth twice, and you cannot have gotten it right only once.
That completes the fullstack tour: contracts, data, effects, failures, identity. What remains is the part users actually see.
The UI Vocabulary
Mar frontends contain no HTML, no CSS, and no SwiftUI. A view function composes elements from a fixed vocabulary, and each platform’s runtime renders that description natively: careful HTML and CSS on the web, real SwiftUI on iOS. This chapter explains the vocabulary, and then defends the controversial part: that it is closed.
Composing a view
view : Model -> View Msg
view model =
navigationStack [ navigationTitle "Sign in" ]
[ form
[ section []
[ text [] "Enter your email and we'll send a code."
, textField [ email, submit Submitted ] "Email" model.draft DraftChanged
]
, section []
[ button [] Submitted "Send me a code" ]
]
]
The grammar has three rules, and you have now seen all of them:
- Elements nest, as function calls building a tree.
- The first argument is always a list of attributes, even when empty (
text []). Adding an attribute never restructures a call. - Interaction points name a
Msg.button [] Submitted "Send me a code"says which message tapping produces;textFieldnames the message its edits carry (DraftChanged, a constructor holding the new text). Views never handle events; they label them.
The vocabulary covers the app staples: navigationStack and navigationTitle for the chrome (with topBarLeading / topBarTrailing slots for bar actions); list, section, header, footer for grouped content; title, subtitle, text, paragraph with span, bold, italic, code, link for words; textField, picker, datePicker, toggle, button for input; vstack, hstack, spacer for arrangement; navigationLink for routes; image; sheet and confirm for modals; list with keys, drag-to-reorder, and swipe-to-delete where a platform supports them.
Each of these is semantic: it says what something is, not how to draw it. That is the whole trick. Because a section is a concept rather than a <div> with classes, the web runtime can render it as a grouped card and the iOS runtime as a native grouped list section, and both look at home.
What you get for giving up pixels
Two platforms from one view, honestly native. Not a web page in a wrapper: the iOS renderer emits SwiftUI, with platform behaviors (large-title collapse, native pickers, swipe gestures) implemented by the platform. The same view function, and neither version reads as a port.
A designed baseline instead of a blank page. Typography scale, spacing, dark mode, focus states, hover behavior on pointer devices and its correct absence on touch devices, safe-area handling on phones: all decided once, in the runtime, by people staring at exactly these widgets. The classic web tax (rebuilding “a decent-looking form” for the nth time) is simply not charged.
Semantic correctness by default. Since the runtime owns the rendering, it owns accessibility hooks, keyboard navigation, and the fiddly interaction details, and fixes to them arrive with the framework rather than being every app’s homework. A bug fixed in the renderer is fixed in every Mar app at once.
Views stay legible. A view function reads as an outline of the screen. There is no style sheet to cross-reference, no cascade to debug, no specificity war. What the function says is what there is.
The cost, stated plainly
You cannot draw outside the lines. If the vocabulary lacks a widget, you cannot drop into HTML for a corner of the page; the escape hatch does not exist (except the canvas, next chapter, which is a different world with different rules). Brand-heavy, design-led products will hit this wall and should probably not choose Mar today. The vocabulary grows deliberately, one well-designed element at a time, rather than by admitting arbitrary markup, because the moment arbitrary markup enters, every guarantee in the previous section quietly leaves.
This is the same trade Mar makes everywhere, applied to pixels: fewer choices, stronger promises. The gamble is that for the target class of app (tools, dashboards, products whose users want clarity more than branding), a genuinely good default beats infinite freedom plus infinite responsibility.
One vocabulary, one reconciler
Under the hood, view output is a description diffed against the previous frame, so only what changed touches the screen. You never manage this. It is worth knowing only because it explains the performance model: view runs freely; the runtime makes it cheap.
For interfaces, that is the whole story. For worlds, there is a second, very different drawing surface.
Canvas, Sound, and Games
A language whose values never change sounds like the last place to write a 60-frames-per-second game. Mar’s examples folder disagrees: a pseudo-3D racer, a raycasting dungeon crawler, a vertical shoot-em-up, a rhythm runner, a real-time strategy game with flow-field pathfinding. All pure Mar, all immutable, all shipping. This chapter is not a game-dev tutorial; it is about why the architecture holds at 60fps, which is the strongest possible stress test of everything this book has claimed.
The canvas is a value too
Canvas is the second drawing surface, for worlds instead of widgets. Where the UI vocabulary is semantic, the canvas is geometric: rectangles, circles, triangles, text, grouped and transformed. A game’s view returns a draw list, an ordinary immutable value describing one frame:
view : Model -> View Msg
view model =
Canvas.canvas [ Canvas.onTap Tapped, Canvas.onResize Resized ]
(List.concat
[ [ Canvas.rect 0 0 model.w model.h (Canvas.rgb 13 9 12) ]
, stars model
, List.map drawShip model.ships
, hud model
])
No retained scene graph, no sprite objects with positions to mutate. Every frame, the world (the Model) is rendered to a fresh description, and the runtime blits it. Groups apply transforms (Canvas.Translate, Canvas.Rotate, degrees as integers) and blend modes to whole sub-lists, which is how a windmill spins or a glow adds light without any element ever being “moved”.
A game is just a fast page
Recall the subscriptions chapter: a game is an MVU page whose subscriptions requests a fast tick and the input streams:
subscriptions model =
Sub.batch
[ Time.every (Time.millis 16) Tick
, Keyboard.onKeyDown KeyDown
, Keyboard.onKeyUp KeyUp
]
update receives Tick and advances the simulation one step: new positions, collisions, spawns, all computed as the next immutable world from the previous one, in fixed-point integer math (positions in sixteenths of a pixel, the racer’s road projection, the crawler’s raycaster: integers throughout, as the numbers chapter promised). The runtime keeps game time honest against wall-clock time even when a device drops frames, so a slow phone sees fewer frames of the same-speed game rather than a slow-motion one.
The remarkable part is what did not need adding: no entity-component system, no special game loop API, no mutable particle pools. The RTS holds hundreds of units; its Model is one record holding lists of units, buildings, and shots; its update is the rules of the game as a pure function. Purity here is not a tax paid for elegance. It is the reason a desync between the game’s server and client replays is structurally impossible in the card game example, whose one rules engine, written in shared Mar, is executed independently by the Go backend and the JS frontend and must agree, and does, because integer math plus pure functions equals bit-identical replay.
Sound as data
The same philosophy reaches audio. Sound is a chip-tune synthesizer: notes, chords, sweeps, vibrato, sequences, described as values and played, looped, or attached to the world by the runtime. The rhythm runner drives its entire soundtrack from the game world itself, one groove step per beat line, so the music cannot drift from the action: they are the same data.
Input rounds out the kit: Keyboard and Gamepad mirror each other as subscriptions, Canvas.onTap / onDrag / onAltTap / onWheel cover pointers and touch, and Device reports capabilities (touch-only, can-hover) so games choose control schemes by facts rather than user-agent guessing.
Why this chapter is in a theory book
Because games are the adversarial audit of the language’s claims. Immutability at 60fps, integers for physics and 3D projection, effects-as-data for real-time audio, one codebase running identical logic on two runtimes: if any of Part I were marketing, this is where it would collapse. Instead the games get the same dividends the CRUD apps get, plus one nobody expects until they feel it: the time-travel debugger works on games. Scrub backward through a boss fight; every frame is just a Model, and Models keep.
The remaining question for any real project is the least romantic one: how does all this get to users?
Shipping It
Deployment is where architectures confess. A stack that needs a container orchestra, a managed database, a queue, and four YAML dialects is telling you something about how many moving pieces it failed to consolidate. Mar’s confession is short: one artifact per app.
The fullstack artifact: a single binary
mar build compiles a fullstack app into one self-contained executable. Inside it: your compiled program, the runtime, the SQLite engine, the web frontend it serves, and every static asset. Not a folder with a binary in it; one file.
Copy that file to any Linux server and run it. It opens its port, creates or migrates its database (a single SQLite file beside it), and serves the app, the API, the admin panel, and the PWA manifest. There is no Dockerfile to write, no runtime to install on the host, no “works on my machine” gap, because the machine’s only job is to execute one static program.
mar deploy automates the common case, shipping that binary to a Fly.io VM: it generates the ephemeral packaging, provisions, checks health after rollout, and prints where your app lives. Secrets travel as environment variables (env: fields in mar.json), never inside the artifact.
The reasoning behind the single-binary bet mirrors the language: fewer moving parts, stronger promises. Every operational seam removed (app-to-database network hop, container-to-host contract, asset-CDN-to-app versioning) is a failure mode deleted rather than monitored. SQLite-in-process is the enabling choice: the database is not a second service to operate; it is a file the app owns. Backups, accordingly, are a framework feature (snapshot bundles of that file plus config), not an ops project.
The frontend artifact: static files
An App.frontend project (no backend of its own, like the games) compiles to a folder: index.html, the runtime, your program, assets. Any static host serves it; mar deploy targets Cloudflare Pages. Every frontend build is an installable PWA out of the box: manifest, icons, home-screen behavior, generated from mar.json.
Over-the-air updates for iOS
The iOS app that mar scaffolds is a native shell around your compiled program, and the program itself is data the shell can refresh. A fullstack deployment serves the current program at a well-known path, and installed apps pick up new logic and UI on next launch, no App Store review cycle for ordinary iteration. (Frontend-only deployments on static hosting do not serve this endpoint, so OTA is a fullstack feature.)
This is a direct dividend of the interpreter architecture from chapter 1: because the program is a value the runtime executes, “update the app” can mean “fetch the new value”.
Development is the same loop, faster
mar dev is the same machinery in a tighter cycle: hot reload on save, migrations on restart, dev sign-in codes printed to the console instead of emailed, the time-travel debugger a keypress away. The artifact you ship and the loop you develop in are the same program in two moods, which is why “it worked in dev” carries unusual weight in Mar.
What is deliberately missing
No Kubernetes story, no multi-region database replication, no horizontal autoscaling of stateless pods. A Mar app is one process with one database file, scaled by making the VM bigger. For the products Mar targets, tools, small SaaS, team apps, games, this covers a startlingly large range (SQLite on modern hardware reads faster than most teams’ managed Postgres round-trips). For products beyond it, Mar is the wrong tool, and says so in its own docs, which is the subject of the final chapter.
Trade-offs, Honestly
Every chapter so far has argued for something. This one audits the price list. Mar’s own documentation keeps a section titled “the honest caveats”, and this book ends in the same spirit, because a tool chosen on marketing is a tool resented in month three.
Where the walls are
The ecosystem is small and young. No package registry of a hundred thousand libraries; expect to write things a JavaScript developer would install (and, in fairness, audit forty transitive dependencies for). Breaking changes still land between versions. Building on Mar today means enjoying frontier weather.
The UI vocabulary is closed. If your product’s identity depends on bespoke visual design, the fixed vocabulary will chafe, and there is no HTML escape hatch by design. Evaluate this wall early: build one representative screen before committing.
Storage is SQLite, one file per app. Phenomenal for its class; a real ceiling for write-heavy, multi-region, or many-service systems. The Repo is deliberately minimal: no query DSL, no relations API, no raw SQL. Most apps never notice; data-analytics workloads will.
Numbers are exact. Int and Decimal cover counting, layout, game math, and money; approximate work with huge dynamic range (scientific computing, signal processing) does not fit, and Decimal caps at 34 significant digits rather than lose precision quietly.
One process, one machine. Scaling is vertical. This covers more than the industry admits, and it is still a boundary.
Interpreted execution. The tree-walking runtime is fast enough for 60fps games, which says a lot, but compute-bound inner loops (video encoding, large-scale simulation) belong elsewhere.
Who the trade suits
Mar concentrates its wins where one person or a small team owns a whole product: internal tools, dashboards, small and medium SaaS, team apps, indie games, personal software with real users. In that setting, the compiler-checked seams and the included scaffolding convert directly into shipped features, and the walls are mostly out of sight.
Signals that Mar is the wrong choice today: a design team with pixel-level opinions; data volume or write concurrency beyond one healthy SQLite file; heavy numerics; a hard requirement on some third-party SDK; an organization that cannot absorb language-version churn.
The deeper bet
Underneath the feature list, Mar is one wager made repeatedly: that guarantees compound better than options. Each removal (mutation, null, exceptions, floats, free-form markup, ad-hoc SQL, the second language) traded local convenience for a global property the compiler can hold forever. Options feel good on day one; guarantees feel good on day four hundred, when the codebase has grown past what anyone holds in their head and the compiler is the only teammate who has actually read all of it.
The Elm community, Mar’s direct ancestor, proved the bet can work for frontends: codebases famous for surviving years of refactoring without runtime exceptions. Mar’s contribution is extending the same wager across the wire, to the database, the API, the auth layer, and the deploy artifact, on the theory that the seams between layers were always where the worst bugs lived.
Whether that extension wins in the large is not yet proven; the language is young and says so. What this book hopes to have shown is that the design is not a pile of restrictions but a single idea applied uniformly, and that the idea is testable on your own next side project, which is, fittingly, exactly the scale Mar recommends for itself.
Build small things first.
Appendix A: Syntax Cheat Sheet
A one-stop reference for the syntax used throughout the book. Everything here is real Mar.
Values and functions
answer = 42 -- a definition (not a variable; it never changes)
double : Int -> Int -- type annotation (optional, recommended on top level)
double n = n * 2
add : Int -> Int -> Int -- two arguments
add a b = a + b
half = \n -> n // 2 -- lambda (anonymous function)
result = users |> List.filter isActive |> List.map score -- pipeline: x |> f == f x
Strings concatenate with ++. Comparison: ==, /=, <, >, <=, >=. Logic: &&, ||.
Numbers
Two number types, both exact: Int (whole) and Decimal (base-10, written 19.99). + - * work on either (never mixed; convert with Decimal.fromInt).
n = 7 // 2 -- integer division, truncates (3); n // 0 == 0
-- no modulo operator; idiom: n - (n // m) * m
third = 1.0 / 3.0 |> Decimal.rounded Decimal.HalfEven 4 -- 0.3333
exact = Decimal.exact (1.0 / 4.0) -- Just 0.25 (Nothing if it repeats)
split = Decimal.withRemainder 2 (100.00 / Decimal.fromInt 3)
-- { quotient = 33.33, remainder = 0.01 }
/ works only on Decimals and returns a Decimal.Division (the unresolved exact quotient); the three resolvers above are the only exits, so every rounding names its mode (Decimal.HalfEven, HalfUp, Down, Up, Floor, Ceiling) and scale at the call site. Handy conversions: Decimal.toString, Decimal.fromString, Decimal.toScale, Decimal.round / floor / ceiling / truncate (to Int), Decimal.fromCents / toCents.
Records
type alias User =
{ id : Int
, name : String
}
u = { id = 1, name = "Ana" } -- build
u.name -- read
{ u | name = "Bia" } -- "update": a new record differing in one field
Unions and pattern matching
type Status
= Open
| Assigned User
| Closed
describe : Status -> String
describe status =
case status of
Open -> "open"
Assigned user -> "assigned to " ++ user.name
Closed -> "closed"
case must cover every constructor. _ is the wildcard pattern. Records destructure in patterns too.
Maybe and Result
case Repo.findById users id of
Just user -> greet user
Nothing -> showNotFound
case outcome of
Ok value -> use value
Err why -> explain why
Conditionals and locals
size = if n > 100 then "big" else "small" -- if is an expression; else is mandatory
area =
let
w = x2 - x1
h = y2 - y1
in
w * h
Lists
xs = [ 1, 2, 3 ]
ys = 0 :: xs -- prepend
names = List.map (\u -> u.name) users
adults = List.filter (\u -> u.age >= 18) users
total = List.foldl (\acc n -> acc + n) 0 xs -- NOTE: the accumulator comes FIRST
Mar’s List.foldl passes the accumulator as the lambda’s first argument (\acc item -> ...). If you arrive from Elm, this is flipped from what your fingers expect.
Modules
module Frontend.Home exposing (page)
import UI exposing (list, section, title, button)
import Frontend.Routes
File path mirrors module name (Frontend/Home.mar is module Frontend.Home). Only what a module exposings is visible. No import cycles.
The MVU page skeleton
type alias Model = { count : Int }
type Msg
= Increment
init : (Model, Cmd Msg)
init = ( { count = 0 }, Cmd.none )
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
Increment -> ( { model | count = model.count + 1 }, Cmd.none )
view : Model -> View Msg
view model = ...
page : Page
page =
Page.create
{ path = "/", title = "Counter"
, init = init, update = update, view = view
, subscriptions = always Sub.none
}
Effects, both sides
-- Frontend: commands
Service.call Shared.addTask { name = draft } Added
Cmd.perform GotNow Time.now
Cmd.batch [ cmdA, cmdB ]
Nav.pushTo Frontend.Routes.home
-- Frontend: subscriptions
Time.every (Time.millis 16) Tick
Keyboard.onKeyDown KeyDown
-- Backend: tasks
let
row <- Repo.findById tasks id -- bind: run, name the result, continue
in
...
Task.succeed value
Task.fail "broken invariant"
Repo.all tasks |> Task.map List.length
The fullstack triangle
-- Shared.mar: declare once
addTask : Service { name : String } AddTaskOutcome
addTask = Service.declare POST "/tasks"
-- Backend: implement
services = [ Auth.protect Shared.addTask addTaskImpl ]
-- Frontend: call
Service.call Shared.addTask { name = model.draft } Added
Appendix B: A Glossary for the Functional-Curious
Functional programming carries fifty years of vocabulary, some of it more intimidating than the ideas deserve. Working definitions, in plain terms, of every term this book uses.
Immutability. Values cannot be modified after creation. “Changing” something means computing a new value. See Part I.
Pure function. Output depends only on inputs; calling it causes nothing outside itself. The same call always returns the same answer.
Side effect. Anything a function does besides return a value: writing a file, sending a request, mutating shared state. In Mar, side effects are described by values (Cmd, Task) and performed by the runtime.
Referential transparency. The property that an expression can be replaced by its value without changing the program. A consequence of purity, and the reason refactoring pure code is safe.
Expression vs statement. An expression produces a value (if in Mar); a statement performs an action (if in most languages). Mar has only expressions.
Union type (also sum type, custom type, algebraic data type). A type whose values are one of several named cases, each optionally carrying data: type Status = Open | Assigned User | Closed.
Constructor. One of a union’s named cases (Assigned), usable both to build values and in patterns to take them apart.
Pattern matching. Inspecting a value’s shape with case, binding its parts as it matches. Checked for exhaustiveness at compile time.
Exhaustiveness. The compiler’s guarantee that a case handles every constructor. The mechanism that turns “add a new state” into a checklist of compile errors instead of runtime surprises.
Maybe a. The standard union for optional values: Just a or Nothing. Mar’s replacement for null, with the crucial difference that the type system tracks it.
Result e a. The standard union for fallible outcomes: Ok a or Err e. Mar’s replacement for exceptions.
Type inference (Hindley-Milner). The algorithm that deduces every expression’s type without annotations. You write types on top-level functions as documentation, not obligation.
Record. A value with named fields, structurally typed: { id : Int, name : String }.
Type alias. A name for a type, usually a record shape: type alias User = { ... }. Aliases describe; unions define.
MVU (Model-View-Update). The frontend architecture: one state value, a pure render function, a pure transition function, connected in a one-way loop by the runtime. Also called The Elm Architecture.
Model. The single immutable value holding all of a page’s state.
Msg. A page’s union of everything that can happen; the only input to update.
Cmd msg. A description of work the runtime should do, whose outcome returns as a message. The frontend’s effect type.
Sub msg. A standing declaration of interest in outside events (time, keys), recomputed from the Model. The frontend’s inbound channel.
Task a. A description of a computation that produces a value when the runtime runs it. The backend’s effect type; reaches the frontend only via Cmd.perform.
let <- (bind). Backend sugar for chaining Tasks: run the right-hand Task, name its result, continue. Mar’s cousin of await.
Managed effects. The umbrella doctrine: user code describes effects as values; the runtime performs them. The foundation under MVU, services, and the Repo.
Service. A typed contract for one client-server call, declared once (Service.declare VERB "/path") and imported by both halves. The frontend calls it; the backend implements it; the compiler keeps them agreeing.
Entity. A typed declaration of a database table, from which Mar derives the schema, migrations, codecs, and admin UI.
Repo. The small module of typed database operations (all, findById, findBy, create, update, deleteById), each returning a Task.
Codec. The encoding between Mar values and the wire or database formats. Fully derived from types in Mar; user code never writes one.
Decimal. Mar’s exact base-10 number type (19.99 means 19.99). Sums and products never round; division returns a Decimal.Division that you resolve by naming a rounding mode and scale.
Fixed-point arithmetic. Representing fractional quantities as scaled integers (cents, sixteenths of a pixel). How Mar’s games run fractional physics on pure Ints.
Structural sharing. The implementation trick that makes immutability cheap: a “modified” value reuses the unchanged parts of the original, which is safe precisely because nothing can mutate them.
Draw list. The canvas’s frame description: an immutable list of shapes, rebuilt each frame from the Model, rendered by the runtime.
Time-travel debugging. Scrubbing an app backward and forward through its history of Models. Possible because states are immutable values and every change came through update.
Sashimono. Japanese joinery without nails or glue; each piece is shaped to fit the others. Mar’s founding metaphor: replace the glued seams between stack layers with compiler-checked joints.