Skip to main content

Linearity — linear and affine types

TL;DR. A linear value must be consumed exactly once. An affine value may be consumed at most once. An ordinary value is unrestricted (used zero or more times). Linearity is a compile-time property — it disappears at runtime.

type affine FileHandle is { fd: Int }; // may be dropped, may not be duplicated
type linear Promise<T> is { pending: Shared<Cell<T>> }; // must be resolved
type Point is { x: Int, y: Int }; // ordinary — dup/drop freely
Status

Core infrastructure is implemented: the three resource kinds (Copy / Affine / Linear) parse, type-check, and propagate through the inference pipeline. Drop-checking across conditional branches and loop carriers is still maturing — see Implementation status below.

Why linearity?

Most type systems track what a value is. Linearity adds an orthogonal question: how many times you may use it. That is exactly the question you ask when a value represents a resource:

ResourceProblem without linearityWhat linearity gives you
File handleDouble-close / leakClose runs exactly (linear) or at most (affine) once
Lock guardUnlock twice / neverMutexGuard can't escape the region that took the lock
Network connectionLeaking socketsEvery Connection ends in .close() — statically
Database transactionAccidental double-commitTx consumed by commit() or rollback(), never both
Cryptographic nonceNonce reuseNonce is linear — reuse is a type error
Physical capabilityAccidental duplicationAdminToken used once per operation
GPU bufferFree-after-unmapMapping consumes the buffer, unmapping restores it
One-shot senderSending twiceoneshot.Sender<T> moves into .send(v) and is gone

Verum's answer — inspired by substructural type theory (Girard, Wadler) and Linear Haskell — is a first-class attribute on the type definition. The compiler then refuses to let you duplicate or drop values that shouldn't be duplicated or dropped.

Compared with alternatives

ApproachLimitsVerum
Rust move/borrowLinearity is implicit — every non-Copy type is affine, no way to forbid drops. Drop trait runs at scope exit.Explicit linear forces consumption; affine matches Rust's default.
C++ unique_ptrNo duplication, but compiler-inserted destructors mean you always drop — no "must be consumed" check.linear statically proves consumption.
Haskell LinearTypesLinearity is on the arrow (a %1 -> b), not the type. Verbose to propagate.Linearity on the type — propagates automatically through records/variants.
Session types (e.g. session-haskell)Separate DSL, hard to mix with normal code.Session types become ordinary linear values — one model.
Runtime resource managersDouble-close caught at runtime, if at all.Caught by the type checker before the program runs.

The three resource kinds

Every type in Verum has a resource kind — a classification the compiler derives from the type definition.

ResourceKind = Copy // unrestricted, bit-copyable
| Affine // at most once
| Linear // exactly once

Rules:

  • A type is Copy if every field/variant-payload is Copy and the type is not declared affine or linear. Primitives (Int, Float, Bool, Char, (), function pointers, &checked T) are Copy.
  • A type is Affine if it is declared affine or any field is Affine.
  • A type is Linear if it is declared linear or any field is Linear.

This is the contagion rule: structural containers inherit the strongest resource discipline of any payload. It is what makes linearity composable without annotation.

type affine FileHandle is { fd: Int };

type MaybeFile is
| None
| Some(FileHandle);
// MaybeFile is automatically Affine — the Some payload is Affine

Syntax

linearity_qualifier = 'linear' | 'affine' ;

type_def = visibility , 'type' , [ linearity_qualifier ] , identifier
, [ generics ] , [ meta_where_clause ]
, 'is' , type_definition_body ;

Both linear and affine are contextual keywords — the three reserved words of Verum remain let, fn, is. You can still name a variable linear if you really want to.

Worked syntax

type affine File is {
fd: Int,
path: Text,
};

type linear Connection<S: ProtocolState> is {
socket: FileHandle,
state: S,
};

// generic with linearity-aware content
type Box<T> is { inner: Heap<T> };
// Box<Connection<..>> is automatically Linear by contagion.

A first example — unclosable files

type affine File is { fd: Int };

fn open(path: Text) -> File using [FileSystem] {
let fd = fs_open(path);
File { fd: fd }
}

fn close(f: File) using [FileSystem] {
fs_close(f.fd)
// f has been moved into this call; it is consumed.
}

fn read_all(f: &File) -> Text using [FileSystem] { ... }

Using it:

fn example() using [FileSystem] {
let f = open("/etc/hosts");
let content = read_all(&f); // borrow, does not consume
close(f); // consume exactly here
// close(f); // ERROR: f has already been consumed
}

If you forget close(f), f is merely dropped — which affine allows. If you want to make closing mandatory, declare it linear:

type linear File is { fd: Int };

fn example() using [FileSystem] {
let f = open("/etc/hosts");
let _ = read_all(&f);
// ERROR at end of scope: f is a linear value and has not been consumed
}

The same program now refuses to compile until you call close(f) on every control-flow path.

Moving, borrowing, consuming

Three operations exist on any value; linearity changes which are allowed.

OperationCopyAffineLinear
Implicit copy let b = a; use(a); use(b)
Move let b = a; use(b)
Drop (end of scope, no consumer)
Borrow &a / &mut a
Pattern match without binding

Borrowing is always allowed regardless of linearity. A reference never consumes its target:

fn peek(c: &Connection<Open>) -> Text using [Net] { ... }

peek takes a shared reference; the caller still owns the linear connection after the call returns.

Consuming by pattern matching

Destructuring a linear record consumes it and yields the fields individually, each with its own resource kind:

type linear Oneshot<T> is { slot: Heap<Cell<T>> };

fn take<T>(o: Oneshot<T>) -> T {
let Oneshot { slot } = o; // o is consumed; slot is Linear
slot.take().unwrap() // slot is consumed here
}

For a linear variant type, every arm of the match must consume the payload:

type linear Session is
| Awaiting(OpenSocket)
| Live(Connection<Open>)
| Closed;

match s {
Awaiting(sock) => sock.close(), // consumes sock
Live(conn) => conn.shutdown(), // consumes conn
Closed => {}, // nothing to consume
}

The compiler verifies that every arm accounts for the payload. Falling through without consuming the payload is a type error (linear) or warning (affine + #[must_use]).

Splitting a linear value

Sometimes one linear value needs to become two, for example a split of a full-duplex connection into a reader and a writer:

type linear Duplex is { rx: Reader, tx: Writer };

fn split(d: Duplex) -> (Reader, Writer) {
let Duplex { rx, tx } = d;
(rx, tx)
}

Both returned halves inherit linearity, so the caller must still consume each.

Borrowing a linear value

Linearity does not break references. A &T or &mut T to a linear T is still a freely-copyable borrow during its lifetime:

type linear Db is { conn: Connection };

fn query_count(db: &Db, sql: Text) -> Int using [SQL] { ... }

fn example(db: Db) {
let n1 = query_count(&db, "SELECT count(*) FROM users");
let n2 = query_count(&db, "SELECT count(*) FROM orders");
// db is still alive and linear; must be consumed below
db.close();
}

Rule of thumb: linearity governs ownership, references govern lifetime. They compose orthogonally, and CBGR (the capability- based generational reference system) makes the lifetime side safe without GC.

Linear function arrows

Verum uses type-level linearity, not arrow-level. But there is one case where an arrow matters: higher-order consumers. A closure that consumes a linear argument cannot itself be duplicated or its single argument would be consumed twice. The compiler infers this automatically:

fn use_once<T>(f: fn(Linear) -> T) -> T { f(make()) }
// The closure passed in is treated as linear-capturing if it moves
// a linear value into its environment.

You rarely need to think about this explicitly; the closure capture rules make the right kind fall out.

Linearity and async

async fn that takes a linear parameter is fine — the parameter is consumed at most once per Future. But awaiting a linear future more than once is a type error:

async fn fetch(tok: linear AuthToken) -> Response using [Net] {
http_get_with(tok).await
}

fn bad(t: linear AuthToken) {
let fut = fetch(t);
let r1 = fut.await;
// let r2 = fut.await; // ERROR: fut is linear, already awaited
}

Future<T> is itself linear when its task captures linear data.

Patterns: canonical linear APIs

Typestate via linear self-parameters

type linear Pending is { socket: Socket };
type linear Open is { socket: Socket, session: Session };
type linear Closed is {};

implement Pending {
fn handshake(self) -> Open throws(HandshakeError) {
let session = negotiate(&self.socket)?;
Open { socket: self.socket, session: session }
}
}

implement Open {
fn send(&mut self, msg: Message) throws(IoError) { ... }
fn close(self) -> Closed {
self.socket.shutdown();
Closed {}
}
}

Every transition consumes the old state and produces the new one. The compiler proves you can never send on a closed connection or close twice.

Builder with compile-time completeness

type linear BuilderWithHost<S> is { url: Text, host: Text };
type linear BuilderWithAuth<S> is { url: Text, host: Text, auth: Auth };

fn start() -> BuilderEmpty { ... }

implement BuilderEmpty {
fn host(self, h: Text) -> BuilderWithHost { ... }
}
implement BuilderWithHost {
fn auth(self, a: Auth) -> BuilderWithAuth { ... }
}
implement BuilderWithAuth {
fn build(self) -> Request { ... }
}

.build() is only callable after .host(..) and .auth(..). The intermediate builders are linear, so you cannot drop them partway through.

One-shot channels

type linear Sender<T> is { ... };
type linear Receiver<T> is { ... };

fn oneshot<T>() -> (Sender<T>, Receiver<T>) { ... }

implement Sender<T> {
fn send(self, value: T) { ... } // consumes Sender, cannot send twice
}
implement Receiver<T> {
async fn recv(self) -> T { ... }
}

Capability tokens

type linear Nonce is { bytes: [Byte; 12] };

fn encrypt(n: Nonce, plaintext: &[Byte], key: &Key) -> [Byte] {
// n consumed; impossible to reuse
...
}

Nonce reuse — the classic cryptographic footgun — becomes a type error. No runtime check, no code review discipline.

Interaction with other features

With refinement types

Linearity and refinements are orthogonal. You can have a refined linear type:

type linear Buffer is { data: Heap<[Byte]>, len: Int }
where self.len <= self.data.len();

The refinement is checked by SMT; linearity is checked by the resource tracker. Neither subsumes the other.

With context system

A linear value flowing through a context boundary is still linear:

fn run<C>(action: fn(Tx) -> Result using C) -> Result
using C + [Database]
{
let tx = db_begin();
action(tx) // tx consumed by action, or action errors
}

With CBGR (three-tier references)

References do not consume, regardless of tier:

ReferenceInteraction with linear T
&TBorrows a linear T without consuming. Lifetime managed by CBGR.
&mut TBorrows exclusively; caller regains ownership after the borrow ends.
&checked TSame semantics; compile-time lifetime proof.
&unsafe TCaller must manually document why borrowing does not violate linearity.

With copatterns

A cofix stream can produce linear values if the consumer drives it linearly. This is how async generators preserve one-shot semantics.

Common pitfalls

"I can't use match because every arm moves the value"

That's the point. If every arm moves the linear value out, the compiler is satisfied. If some arms don't consume, you get an error — and that's a real bug: a resource leak on that path.

"I need to share a linear value between tasks"

You can't — that would violate single-consumption. Wrap it in Shared<Cell<T>> or pass it through a channel to the consumer task. "Shared ownership" and "linearity" are contradictory; linear values belong to exactly one consumer.

"I want linear, but my field is Int, which is Copy"

Linearity propagates up, not down. A linear record can contain Copy fields; reading those fields by pattern match consumes the record and releases the primitives. The primitives are then freely usable.

"Drop runs destructors for me in Rust, why is Verum different?"

Verum's linear specifically forbids this convenience because silent drops hide bugs. If you want automatic cleanup at scope exit, use affine plus a #[must_use] attribute — the compiler will warn you but not stop you.

"I forgot to consume in one branch of an if"

Diagnostic:

error[E0411]: resource `tx` not consumed on all paths
--> src/bank.rs:42:5
|
42 | if amount > 0 {
| ^^^^^^^^^^^^^^
43 | tx.commit();
| --- consumed here
44 | }
| - but on the `else` path, `tx` is still live
= help: either `tx.commit()` / `tx.rollback()` on both branches,
or move the use outside the if.

Linearity and generics

A generic over T: Any is allowed regardless of T's resource kind, but the compiler propagates kind constraints:

fn store<T>(slot: &mut Option<T>, v: T) {
*slot = Some(v);
}

If called with T linear, the argument v must flow into exactly one use — in this case, into Some(v), which is one use. The generic code doesn't care; the instantiation does.

To explicitly require a kind, use a linearity bound:

fn drop_silently<T: Affine>(_: T) {}
// callable only when T is Copy or Affine, never Linear

(Affine and Linear are built-in kind traits; Copy is the default when no bound is given.)

Linearity and protocols

Protocol methods can take self, &self, or &mut self. On a linear type, fn(self) consumes the receiver:

type linear File is { fd: Int };

implement File {
fn close(self) { fs_close(self.fd) } // consumes
fn size(&self) -> Int { fs_size(self.fd) } // borrows
fn write(&mut self, bytes: &[Byte]) { ... }
}

Protocols that want to be implementable by linear types must declare the receiver carefully — otherwise implementers can't provide the method.

Implementation status

FeatureStatusNotes
linear / affine type_defStabledeclared at the type level
Resource-kind trackingStablethree kinds: Copy / Affine / Linear
Contagion rule (field-driven kind)Stablea record inherits the strictest field's kind
Consume-once on linear selfStableenforced in inference
Drop-check across if/match armsMaturingevery arm must consume / move equivalently
Drop-check across loopsExperimentalcovered by linearity test suite
Linear / Affine kind bounds in genericsExperimentalparser understands them, inference partial
Linearity-polymorphic arrowsPlanned

Track progress in vcs/specs/L1-core/linearity/ and the roadmap page.

FAQ

Is linearity inherited by &T? No. References are always unrestricted; only the underlying owner is tracked.

Can I implement Clone for a linear type? No. That would be a contradiction — .clone() by definition duplicates. You can implement a .split(self) -> (Self, Self) method if the semantics genuinely permit it (e.g., a read-half/write-half pair).

Does linearity impact runtime performance? No. It is a compile-time discipline. The generated code is identical to what a Rust programmer would write by hand.

What about Shared<T> and Heap<T>? Shared<T> is reference- counted and therefore duplicable, so it cannot wrap a linear payload directly. Heap<T> is a unique owning box and freely carries linear payloads.

What's the difference from Rust's ownership? Rust's non-Copy types are already affine — move-only, droppable. Verum adds the linear tier for types that must be consumed, and makes both levels first-class declarations on the type.

Can I mix linear and non-linear fields? Yes. The record's kind is the strongest of its fields. Reading a non-linear field by pattern match consumes the record.

See also