Linear Types

A linear value must be used exactly once — the compiler rejects code that drops it without consuming it, or uses it twice. This makes resource leaks and double-frees impossible to write.


Declaring a linear type

A plain type is unrestricted by default — any binding of it can be copied, dropped, or used any number of times. Linearity is requested at the binding site, with linear let, or on a function parameter with linear:

type FileHandle = FileHandle(Int)

fn read_file(path : String) : String do
  linear let fh = open_file(path)
  -- fh must be used before the function returns — the compiler tracks this
  let content = read_all(fh)   -- consumes fh
  content
end

Forgetting to use fh is a compile error:

fn bad(path : String) : String do
  linear let fh = open_file(path)
  "oops"    -- error: "The linear value `fh` was never used."
end

And using it twice is also a compile error:

fn also_bad(path : String) : String do
  linear let fh = open_file(path)
  let a = read_all(fh)
  let b = read_all(fh)   -- error: "The linear value `fh` is used more than once here."
  a ++ b
end

Always-linear types

always_linear type promotes every binding to linear without per-use-site annotations:

always_linear type DbConn = DbConn(Int)

Useful when you want the linearity guarantee enforced everywhere in the codebase, not just where the author remembered to write linear let.


Typestate: handles that track state

A typestate handle adds a phantom state parameter to an always_linear type, so the compiler can track not just “used exactly once” but which state the value is in at every point in the program:

always_linear type FileHandle(s) = FileHandle(Int)

tag FileTag
tag Closed
tag Open

State transitions are declared with transitions. Each via function must take the handle in its from state and return the handle — and only the handle, with no Result wrapper and no tuple — in its to state:

fn open_conn(h : FileHandle(Closed)) : FileHandle(Open) do
  ...
end

fn close_file(h : FileHandle(Open)) : FileHandle(Closed) do
  ...
end

transitions FileHandle do
  FileTag: Closed -> Open   via open_conn
  FileTag: Open   -> Closed via close_file
end

Calling close_file on a FileHandle(Closed) — or open_conn on a FileHandle(Open) — is a compile error: the compiler tracks exactly which state the handle is in and rejects the call before it ever runs.

Two things fall out of via’s “handle in, bare handle out” shape:

  1. Acquiring the first handle is not itself a transition. open_file takes a String, not a FileHandle — the transitions block only covers moves between states of a value you already hold, not creating that value in the first place. It’s an ordinary function, free to return Result for the acquisition to fail:

    fn open_file(path : String) : Result(FileHandle(Closed), String) do
      ...
    end
    
  2. An operation that also returns data can’t be declared as a via transition — its return type would be a tuple, not a bare handle — but it’s still typestate-checked, for free, by its argument type. read_chunk only accepts a FileHandle(Open), so calling it before open_conn is exactly as much a compile error as calling close_file twice would be; it just isn’t listed under transitions, since it can’t be:

    fn read_chunk(h : FileHandle(Open)) : (String, FileHandle(Open)) do
      ...
    end
    

Putting the pieces together:

fn process(path : String) : Result(String, String) do
  let? h0        = open_file(path)          -- FileHandle(Closed)
  let h1         = open_conn(h0)             -- FileHandle(Open)      — declared transition
  let (data, h2) = read_chunk(h1)             -- FileHandle(Open)      — checked via argument type
  match close_file(h2) do                     -- FileHandle(Closed)    — declared transition
    FileHandle(_) -> Ok(data)                 -- consumes the final handle
  end
end

Skipping the open_conn step and calling read_chunk(h0) directly is rejected at compile time:

expected `Open` but got `Closed`.

    let (data, h2) = read_chunk(h0)
                                 ^^
    This is argument #1 of a function call.

with for linear resource scopes

with pairs acquisition with guaranteed cleanup — useful when you want RAII-style deterministic release. It’s an alternative to let? for the same kind of Result-returning acquisition; here it drives the same state machine as process above:

fn process_with(path : String) : Result(String, String) do
  with Ok(h0) <- open_file(path) do
    let h1         = open_conn(h0)
    let (data, h2) = read_chunk(h1)
    match close_file(h2) do
      FileHandle(_) -> Ok(data)
    end
  else
    Err(e) -> Err(e)
  end
end

Just like any other linear binding, a handle acquired through with is tracked for double-use and drop: consuming h0 twice, or leaving h2 unconsumed, is a compile error the same way it would be with a plain let.


Complete example: safe socket lifecycle

mod Net do
  always_linear type Socket(s) = Socket(Int)

  tag SockTag
  tag Connected
  tag Disconnected

  fn connect(addr : String) : Result(Socket(Connected), String) do
    ...
  end

  fn disconnect(sock : Socket(Connected)) : Socket(Disconnected) do
    ...
  end

  -- `send_bytes`/`recv_bytes` take or return more than a bare handle (a
  -- payload alongside it), so — like `read_chunk` above — they can't be
  -- listed in `transitions`. Their `Socket(Connected)` argument type still
  -- makes calling them out of order (e.g. after `disconnect`) a compile
  -- error; `transitions` only needs to cover the pure handle-to-handle edges.
  fn send_bytes(sock : Socket(Connected), data : String) : Socket(Connected) do
    ...
  end

  fn recv_bytes(sock : Socket(Connected)) : (String, Socket(Connected)) do
    ...
  end

  transitions Socket do
    SockTag: Connected -> Disconnected via disconnect
  end

  fn echo_once(addr : String) : Result((), String) do
    let? sock0       = connect(addr)
    let (msg, sock1) = recv_bytes(sock0)
    let sock2        = send_bytes(sock1, msg)
    match disconnect(sock2) do
      Socket(_) -> Ok(())
    end
  end
end

recv_bytes consumes sock0 and returns (msg, Socket(Connected)) — a fresh handle. Using sock0 again after that — say, passing it to a second recv_bytes call — is a compile error: sock0 is linear, and it was already consumed. Forgetting to call disconnect at the end would also be a compile error — the final handle would be dropped without being consumed. And calling send_bytes or recv_bytes after disconnect fails too: their parameter type is Socket(Connected), and by then all you have is a Socket(Disconnected).

march — interactive
Click run on any snippet to try it here.
march>