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.