Linear and Affine Types
Some values represent things that need to be handled with care: a file that must be closed, a network connection that must be released, a lock that must be given back. Forget to do it and you leak a resource; do it twice and you get a crash or corruption; hand the same one to two different parts of your program and you get a race.
March’s type system can enforce these rules for you, at compile time, with no runtime cost. Think of a linear value as a claim ticket: you’re given it once, and the compiler requires you to hand it in exactly once: not zero times, not twice. An affine value is a looser cousin: you’re allowed to lose it, but you still can’t use it twice. Both are checked entirely by the compiler; there’s no garbage collector or runtime tracking involved.
The Problem They Solve
Consider a file handle or database connection. These resources must be:
- Used: you shouldn’t open a file and forget to read or close it
- Closed exactly once: closing twice is a bug
- Not shared: concurrent access through the same raw handle leads to data corruption
In most languages, these are programmer responsibilities enforced by convention and code review. In March, the type system enforces them.
Linear vs Affine
| Qualifier | Usage count | Meaning |
|---|---|---|
linear |
Exactly once | Must be used; dropping it is a compile error |
affine |
At most once | May be dropped (unused), but cannot be used twice |
Both prevent duplicating (using twice). Linear additionally prevents discarding (never using).
Linear Values
A linear type must be used exactly once:
fn consume(linear h : Handle) : () do
-- h must be used here — the compiler tracks this
close(h)
end
If you forget to use a linear value, the compiler reports an error:
fn bad(linear h : Handle) : () do
()
end
The linear value `h` was never used.
Linear values must be consumed exactly once — did you mean to pass it somewhere?
If you try to use it twice:
fn also_bad(linear h : Handle) : () do
close(h)
close(h)
end
The linear value `h` is used more than once here.
Linear values must be consumed exactly once — they cannot be copied or ignored.
Note: the stdlib’s own Handle type (used for files and similar resources) is
always linear, even without writing the linear keyword; see always_linear
types below for how that works.
Linear Let Bindings
fn read_file(path : String) : String do
linear let handle : Handle = open_file(path)
let content = read_all(handle) -- consumes handle
content
end
The linear let annotation tells the compiler this binding has linear semantics. The
rule to remember: the qualifier has to appear where you bind the value, either as
linear let or as a type annotation (let h : linear Handle = ...). It also comes
from the function you’re calling: if open_file returns a linear Handle, a plain
let h = open_file(p) is tracked as linear, and dropping h is an error.
Affine Values
An affine type may be used zero or one times. This is useful for values that have a cleanup operation but where “not using” is acceptable (e.g., an optional connection).
Spelling: affine works as a type modifier inside the annotation
(cap : affine NetworkCap, below) and as a parameter keyword
(fn f(affine cap : NetworkCap)). There is no affine let:
fn maybe_connect(cap : affine NetworkCap, should_connect : Bool) : () do
if should_connect do
connect(cap)
else
() -- OK: no error when cap goes unused on this path
end
end
The key property: you still cannot use an affine value twice. The second use rejects with:
The affine value `cap` is used more than once here.
Affine values may be used at most once.
Linear Record Fields
Individual fields of a record can be linear:
type Resource = {
linear fd : FileDesc,
metadata : String
}
The record owns its linear fields, and a field whose type is an
always_linear type counts as a linear field too:
- Accessing a field moves it out.
r.fdconsumes that field; a second access rejects withThe linear value `r.fd` is used more than once here. - Using the whole record moves every field it still holds: passing
ralong, returning it, storing it. Consumer.fdand then passralong, andfdhas been used twice. { r with … }keeps every field you don’t replace. After consumingr.fd, write{ r with fd: new_fd }.- Every linear field must be consumed by the end of the record’s scope, or the compiler reports it as never used.
- Reading an ordinary field moves nothing:
r.metadataleavesr.fdalone.
These rules apply to let-bound records, parameters, and an actor’s state
alike.
One more note: arithmetic on linear primitive fields works: e.g.
r.count + 1 for a linear count : Int field is a valid single use.
always_linear Types
So far, linear has been something you write at each use site. always_linear type
is the alternative for when you’re defining a type and want it to be linear
everywhere, automatically; no one who uses your type has to remember to write
linear themselves:
always_linear type Token = Token(Int)
fn main() : () do
let t = Token(1)
() -- error: The linear value `t` was never used.
end
This is exactly how the stdlib guarantees you can’t forget to close a file: Handle is
declared always_linear, so every Handle value, everywhere in your program, is
tracked as linear whether or not you write the word linear at all.
Same-named types don’t inherit linearity. A plain type of your own called
Handleis an ordinary type, even though the stdlib’sHandleis linear: the compiler resolves whichHandlea name refers to before deciding.
Why This Also Makes Programs Fast
You don’t need this section to use linear types correctly; skip ahead to Linear Types and Actors if you just want the safety picture.
Linearity isn’t only about correctness; it also feeds March’s in-place memory model.
Normally, the compiler has to track “is anyone else still holding onto this value?”
before it can safely reuse or drop its memory. A linear value answers that question
for free: because the type system guarantees there’s exactly one reference to it, the
compiler can skip that bookkeeping and reuse memory in place where a value would
otherwise need to be reference-counted or copied. Sending a linear value to an actor is
a real example: it compiles to a zero-copy ownership-transfer move instead of a byte
copy, because the type system already proves no one else can be looking at it.
These are performance facts, not semantic ones: linearity is compile-time-erased, and no check repeats it at runtime. See Memory Model for the full picture.
Linear Types and Actors
If you haven’t read Actors yet, the short version:
send(pid, msg) delivers a message to another actor asynchronously. Sending a linear
value to an actor is allowed, and the send itself is the consuming use:
linear let r : Res = R(7)
send(pid, StoreRes(r)) -- consumes r
take(r) -- error: The linear value `r` is used more than once here.
On the compiled backend the transfer is a zero-copy move; interpreted, it is an ordinary handoff; either way the type system prevents you from touching the value after the send.
An actor’s state holds linear values the way a record does: in a handler, state
owns the state’s linear fields under the record rules above. So
{ state with st: advance(state.st) } is fine, while consuming state.st and then
returning { state with n: k } is an error, because the update would keep the old
st. A handler that returns a brand-new record must consume the old linear fields
first.
A Preview: Session Types
March also uses linearity to enforce conversation protocols between two parties: a strict two-party “who sends what, in what order” agreement, checked at compile time. A channel endpoint is a linear value: each send or receive consumes your current endpoint and hands you back a new one representing “what’s allowed next,” so using a channel out of order, or using it after it’s closed, is a type error rather than a runtime surprise.
protocol Transfer do
Client -> Server : Int
Server -> Client : Int
end
fn client_side(ch : Chan(Client, Transfer)) : Int do
let ch2 = Chan.send(ch, 42) -- send consumes ch, returns a new endpoint
let (result, ch3) = Chan.recv(ch2) -- recv returns (value, new endpoint)
Chan.close(ch3)
result
end
This is really just linear types applied to a channel instead of a file handle, the same “exactly once, in the right order” discipline you’ve already seen above. See Session Types for the full protocol syntax, branching, and the precise guarantees (and their current limits).
Capabilities as Linear Types
A quick reminder if you haven’t read Capabilities
yet: a Cap(X) value is proof that your code is allowed to perform the effect X (like
Cap(IO.Network) for opening sockets); it’s how March makes permissions part of the
type system instead of a runtime check.
Cap(X) is, by default, an ordinary unrestricted type: a plain Cap(X) value can
be passed to as many callees as you like. Preventing a capability from being forged is
a separate mechanism entirely (see the capabilities page). What you can do is apply
the ordinary linear qualifier to a capability parameter, exactly as to any other
value, when a function should force its caller to give up the capability for good:
fn narrow_once(linear cap : Cap(IO)) : Cap(IO.FileRead) do
cap_narrow(cap) -- consumes cap; the caller cannot reuse it afterward
end
Capability narrowing attenuates a capability to a sub-capability:
fn restricted_op(cap : Cap(IO)) : () do
let console_cap = cap_narrow(cap) -- Cap(IO) -> Cap(IO.Console)
greet(console_cap, "Alice")
end
FFI and Native Resources
This section only matters if you’re binding to a C library; skip ahead to Practical Rules otherwise.
There is no Ptr type in March. The mechanism for safe manual memory management across
the FFI boundary is the resource declaration together with the consume parameter
mode. A resource type is an opaque native handle that is reference-counted like any
other value, invoking its destructor automatically when the last reference is dropped;
consume on an extern parameter transfers ownership into that call so the compiler does
not also auto-drop the binding afterward (which would double-free):
mod Bindings do
needs IO.Foreign
needs IO.FileSystem
resource Buffer
extern "libc": Cap(IO.FileSystem) do
fn buffer_alloc(n : Int) : Buffer
fn buffer_free(consume buf : Buffer) : ()
end
end
This makes the ownership transfer explicit in the type: buffer_free
consumes buf, so a later use of buf in the same scope is a compile
error, the same double-use rejection this chapter has covered throughout.
Linear Values in Closures, Containers, and Generic Code
A linear value has to stay traceable wherever it goes:
- Closures can’t capture one. A closure may run more than once, so an outer linear value can’t be used inside one; pass it in as a parameter. And a lambda’s own linear parameters must be consumed, just like a function’s.
_can’t discard one.let _ = tokenorfn _ -> …receiving a linear value would drop it. Discarding a non-linear part (Token(_)) is fine.- A container holding one is linear itself.
(token, 1),Some(token)and[token]must be used exactly once, like what they hold. Records are the exception: their fields are tracked one by one. -
Generic functions must opt in. A generic function may drop or duplicate a value of its type parameter, so it can only receive a linear value if it marks that parameter
linear:fn dup(x) do (x, x) end -- dup(token): rejected, dup may duplicate fn id(linear x : a) : a do x end -- id(token): fine, x is checked linearConstructors, operators, and functions that only return their type variable need nothing. Most stdlib generics haven’t opted in, so
List.length([token])is rejected: it would drop the token.
Practical Rules
-
Use
linearfor resources with mandatory cleanup: file handles, database connections, exclusive locks, capabilities you must return. -
Use
affinefor optional-use tokens: things you might or might not use, but definitely shouldn’t use twice. -
Ordinary values need no qualifier: the default is unrestricted (can be copied, dropped, used many times).
-
Branches must agree. A linear value that one branch of an
if,matchormatch doconsumes must be consumed by every branch that returns; a branch that ends inpanic(…)never returns and doesn’t count. The earlyErrreturn oflet?is a branch too, so consume linear values before alet?that could skip them. Affine values and session-channel endpoints may still be dropped on a branch. -
Linear fields in records are owned by the record: accessing one moves it out, using the record whole moves them all,
{ r with … }keeps the ones it doesn’t replace, and each must be consumed by the end of the record’s scope. -
Closures, wildcards and generic code don’t get a pass: a closure can’t capture a linear value,
_can’t discard one, a tuple/list/ADT holding one is linear too, and a generic function receives one only through a parameter markedlinear.
Why Both?
Many systems have only one kind of linear type. March has both because they solve different problems:
linearensures you can’t forget to do something (close, release, respond)affineensures you can’t duplicate something, while allowing graceful abandonment
For example, a session channel is meant to be completed; it is linear by construction (though the “can’t abandon it midway” part isn’t fully enforced today for unclosed channels; see the caveat on the Session Types page). An optional permission token might be affine: the operation is valid with or without it.
Next Steps
- Type System: the broader type system context
- Refinement Types: the other compile-time safety layer: value predicates checked by an SMT solver
- Actors: how linear types interact with actor message passing
- Pattern Matching: destructuring linear values