Returned enums that carry references
Status: Phase 1 implemented (issue #520). Functions and methods can return
Option[&T], Option[&mut T], Result[&T, E], and nested success payloads of
these; callers match, propagate, unwrap, inspect, print, forward, or hold the
result in an immutable local. The reference is authoritative:
optional and fallible references.
Phase 2 (mut holders, parameters) and phase 3 (pipelines) are not
implemented, and standard library getters such as dict.get_ref remain
follow-up work under B11. This page is the design record; examples below may
predate the implemented diagnostics.
Problem
“Find it, or say it is absent” is one of the most common things a program does. In Plenty today a function cannot return a borrow that may be absent:
def find(items: &list[Item], name: &str) -> Option[&Item]:
# ^ references cannot be stored in enum payloads
Every container that holds non-copyable values works around this, and each workaround costs something:
| Workaround | Where it is used | Cost |
|---|---|---|
Callback getters, get_with(&key, &f) -> Option[R] | std.cache.lru get_with, peek_with | A callback per lookup; no ?, return or break from inside it; an extra type parameter in every signature |
| Trapping getters plus a membership test | LruCache.get_ref/peek_mut, Heap.peek, graph.node/edge | Two lookups (contains then the getter); a missing key is a crash rather than a value |
| Return an index, then index again | std.http.find, std.http.cookie.find, std.http.body.find, std.json.find, std.toml.find | A second bounds check; the index is not tied to the collection, and private layouts (LRU slots) cannot expose one |
| Copying lookup | dict.get, list.get | Rejects affine values (“get cannot return owned dictionary values”) |
| Rejected outright | next(p), find and reduce on pipelines of &T items | “references cannot be stored in a find result; map to an owned value first” |
The lookup lesson currently tells
learners “Optional borrowed lookup is not implemented”. std.graph traps on an
invalid handle by calling itself with an out-of-range index, because a getter
cannot report absence.
Plenty avoids lifetime annotations with two rules: a returned reference borrows
from the first reference parameter (the origin), and references never enter
aggregates. This proposal keeps both. It adds one narrow exception: an
Option or Result may carry a reference as a value in flight, produced by
a call under the origin rule and consumed locally, but never stored.
What exists today
Probes ran on local main at 13579f12 with a debug compiler
(plenty --check).
| Probe | Result | Consequence |
|---|---|---|
def find(xs: &list[Item], n: &str) -> Option[&Item] | Rejected: references cannot be stored in enum payloads | The motivating signature |
-> Result[&str, i64], o = Some(&xs[0]), o: Option[&i64] = Nothing | Same rejection | Construction and annotation share one barrier (Ty::contains_reference) |
enum Found: Hit(&str) | Rejected at the declaration | User enums use the storage-barrier check |
-> (&str, i64), t = (find(&xs, "a"), 1) | Rejected: references cannot be stored in tuples | Tuples are aggregates; out of scope here |
Callable[[&str], Option[&str]] | Same rejection | Callable signatures share the type check |
def pick[T](x: T) -> Option[T] called with &s | Rejected: reference type arguments are not supported | A type parameter is never a reference; generic user enums cannot carry one |
d.get("a") with list[i64] values | Rejected: get cannot return owned dictionary values | Affine values have no optional lookup |
next(p), iter(&xs).find(f) with &str items | Rejected: references cannot be stored in a next/find result | Pipelines hit the same wall |
Return a &mut element on one path, xs.append(...) on the other | Accepted and runs | Loans already end per path, so get-or-insert shapes work |
def touch(xs: &mut list[i64]) -> &i64, then read xs while the result is live | Accepted | A shared result from an &mut origin holds a shared loan |
Payload reference returned from match &node.next | Accepted | Borrowed-match payloads already satisfy the origin rule |
Relevant implementation facts:
- Representation. Wide values (enums, classes) and references are 128-bit
slots. A reference’s low word is a slot address; its high word is a tag
shift, non-zero only after projecting into a nested sum payload
(
src/codegen/references.rs). Enum tags stack in the high word: wrapping is(payload_tags << tag_bits) | tag, unwrapping shifts back (src/codegen/enums.rs,wrap_tagged,tagged_payload). There is no niche optimization. - Origins. The checker picks an origin only when the result type is
Ty::Ref(src/frontend.rs,return_origin);check_return_referencecompares each return path’s loan root with it, andcall_reference_resultgives the caller a child loan of argument 0’s loan (src/frontend/references.rs). The ownership pass itself only seesLoan/UseLoan/EndLoanops, so it is type-agnostic. - Local loan carriers that are not references already exist.
KeyViewvalues andIter[T]pipelines hold loans per slot throughreference_locals; neither can be returned. ?and builtins.?consumes the operand and moves the payload out (lower_try).unwrap,unwrap_or,ok_or,is_someand friends are compiler builtins (src/frontend/collections.rs), not generic Plenty methods; neither?norunwrapforwards a loan today. There is nomaporand_thenonOption/Result, and noif let/while letform:matchis the only pattern construct.
Precedents
| Language | Optional borrow | How it stays sound |
|---|---|---|
| Rust | Option<&T>, Result<&T, E>, any enum | Lifetime parameters, elided for one input reference; references may be stored anywhere a lifetime allows |
| Swift | No borrowed optionals; Optional<T> copies or retains | Reference counting; inout/borrowing parameters cannot be returned |
| Hylo (Val) | No first-class references; subscript yields a projection for a scope | Projections cannot escape their scope |
Rust’s lifetime elision rule (“one input reference: the output borrows it”) is Plenty’s origin rule. Rust needs annotations only for the cases this proposal continues to reject: several possible origins and stored references.
Proposal
The rule
A reference-carrying enum is an Option or Result type in which a
variant’s payload is a reference, directly or through further Option/Result
payloads: Option[&T], Option[&mut T], Result[&T, E],
Result[Option[&T], E]. Its reference payloads behave exactly like a returned
reference, and the value itself is never stored:
- Produced by a call or a local construction. A function or method may
declare a reference-carrying enum as its return type. Every return path,
including each
?that propagates, must originate in the first reference parameter under the existing returned-reference rule.Nothingand payload-free variants originate in nothing and are always accepted. A function without a reference parameter cannot declare one, and an&mutpayload requires an&mutorigin. - Held by a local. The caller may bind the result to a local, which holds
the origin loan until the last use of the local and of every reference
extracted from it.
Some(&place),Ok(&place)andErr(&place)may be bound to a local or returned; the binding holds that place’s loan, asr = &placedoes. - Consumed by inspection. It may be matched, propagated with
?, tested (is_some,is_ok, …), unwrapped, printed, or passed straight toreturn. - Never stored. It cannot enter a tuple, class field, list, dictionary,
set,
Box, closure capture, another enum value, a scoped worker, a channel, or a live state acrossyield. It cannot be a type argument other than the payload position ofOption/Result, solist[Option[&T]],Generator[Option[&T]]andBox[Option[&T]]stay rejected.
Rule 4 is the existing “references never enter aggregates” rule with no
exception: the enum is the reference’s wrapper, not a container it is stored
in. A nested type such as Result[Option[&T], E] is written in one signature
and produced as one value; wrapping an existing Option[&T] local in Ok(...)
at a return is permitted for the same reason, and is the only place an enum
value may appear inside another.
class Item:
name: str
tags: list[str]
def find(items: &list[Item], name: &str) -> Option[&Item]:
for item in items:
if item.name == name:
return Some(item)
Nothing
def main() -> Result[(), AllocError | IoError]:
mut items = [Item("ada", ["x"]?)]?
match find(&items, "ada"):
case Some(item):
print(item.tags)? # `item: &Item`; `items` is borrowed here
case Nothing:
items.append(Item("ada", []?))? # the loan ended at the match
Ok(())
Which enums qualify
| Option | For | Against |
|---|---|---|
Option only | Smallest change; covers get, find, first | Fallible borrowed lookups (Result[Option[&V], E], a parser returning Result[&Token, ParseError]) remain impossible |
Option and Result, nested in each other (recommended) | Covers infallible and fallible lookups; both are builtin sums whose methods are compiler builtins, so no generic instantiation with a reference type argument is needed; ? already treats them specially | Two types to document instead of one |
| Any enum, including user enums | Uniform | User enum payloads come from declarations or type parameters. A declaration naming &T would be the first user type with a reference field, which reads as “references can be stored”. A generic Lookup[&V] needs reference type arguments, which Plenty does not support and which would make every generic body a potential reference holder |
Recommendation: Option and Result only. User enums keep the rule that
declarations and type arguments never contain references. Revisit only with a
concrete case that Result[Option[&T], E] cannot express.
Anonymous error sums (AllocError | IoError) are Result error types and may
not contain references; an error that borrows its input would escape through
? into unrelated callers’ error unions.
Matching, ? and builtins
matchby value on a reference-carrying enum binds payload names as the reference type itself (item: &Item,slot: &mut V). Each binding is a child of the enum local’s loan, like a borrowed-match payload. In an arm without payload bindings, the enum’s last use is the scrutinee, so the loan ends there; this is what lets theNothingarm above mutateitems.match &ois rejected: a reference to a reference-carrying enum would be a reference to a reference, which Plenty does not have. The diagnostic says to matchodirectly. Matching a sharedOption[&T]by value copies only the reference and leavesousable.?returns early onNothing/Errand yields the payload reference, which inherits the operand’s loan:item = find(&items, "ada")?is a reference binding from a call result. Propagation is a return path: in a function returningOption[&T],?onNothingreturnsNothing, which borrows nothing, and the extracted reference must still reach any returnedSomefrom the origin.is_some,is_none,is_ok,is_errread the tag; they are uses of the value and extend its loan to that point only.unwrap()yields the reference with the operand’s loan and traps on absence, the same as indexing.ok_or(error)turnsOption[&T]intoResult[&T, E]with an owned error; the loan carries over.unwrap_or(default)with a reference default is rejected. The result could borrow either the option’s origin ordefault, which is a conditional reference expression; those are unsupported, and supporting them only here would give one builtin a rule no function signature can state. The diagnostic suggestsmatch.- Printing reads through the reference (
Option[&str].Some("ada")). Equality is not extended in phase 1.
match is the only pattern form; if conditional forms such as if let are
added, they bind payloads as match does. Should map/and_then combinators be added later, they follow from the
signature rule: the receiver is the first reference-carrying parameter, so
o.map(f) may return a borrow only through f’s own origin, its argument.
Err payloads
Recommendation: in Result, only the Ok payload may carry a reference.
Error values are routinely widened into anonymous sums, printed by main, and
propagated far from their origin; a borrowed error would make ? in an
ordinary Result[(), IoError | E] caller fail with an ownership error. Errors
that need to describe input should copy the relevant part, as they do today.
&mut payloads and aliasing
Option[&mut T] behaves as an exclusive reference. It is affine: a by-value
match, ?, unwrap, passing it, or returning it moves it, and the extracted
&mut T takes over the loan. While the value or its payload binding is live,
the origin is exclusively borrowed, so two get_mut results from one
dictionary cannot be live together (dictionary keys already protect the whole
collection). A shared result from an &mut origin holds only a shared loan,
as today: cache.get_ref(&k) may promote the entry through &mut self and still
let the caller read cache.len() while the result is live.
Get-or-insert works without new rules, because loans already end per path:
def counter(counts: &mut dict[str, list[i64]], key: &str) -> Result[&mut list[i64], AllocError]:
match counts.get_mut(key):
case Some(found):
return Ok(found)
case Nothing:
pass
counts.insert(*key, []?)?
Ok(&mut counts[key])
Loans, last use and temporaries
The loan model is the one returned references already use. A
reference-carrying local is a loan holder like a reference local; payload
bindings, ? results and unwrap results are child loans. The origin loan
ends at the last use of all of them on each path. A precise field origin stays
precise; indexed and control-dependent bodies protect the whole argument, as
they do for &T results.
Temporaries follow the returned-reference rules unchanged. A method returning
a reference-carrying enum needs a named receiver or a place in named storage.
With a temporary shared argument the result is usable only within its full
expression: print(lookup(&row(), "id").is_some())? works; binding the result
is rejected.
Mutable locals
A mut reference-carrying local may be reassigned, which is how a search
remembers its best match without an index:
def longest(words: &list[str]) -> Option[&str]:
mut best: Option[&str] = Nothing
for word in words:
match best:
case Some(current):
if word.byte_len() > current.byte_len():
best = Some(word)
case Nothing:
best = Some(word)
best
It follows reference rebinding:
the first reference it holds fixes its first place, later Some(...)
assignments must stay within that place, and assigning Nothing keeps the
first place. A binding initialized with Nothing takes its first place from its
first Some assignment. Recommendation: phase 2, after immutable locals have
shipped, because it touches the rebinding checker.
Parameters, generics and Callable
- Parameters. Recommendation: allow reference-carrying enum parameters in
phase 2. They give optional borrowed arguments
(
def render(node: &Node, parent: Option[&Node])) and let helpers take a lookup result. Such a parameter counts as a reference parameter when choosing the origin, sodef or_first(found: Option[&T], xs: &list[T]) -> &Tmay return onlyfound’s payload. Passing a shared one copies it; passing an&mutone moves it. - Generics. Type parameters still never bind references, so
Option[T]withT = &Ustays rejected; a generic function must spellOption[&T]. Its body is checked per instance, as reference-returning generic functions are now. Generic class methods such asLruCache[K, V].get_ref -> Option[&V]need no copyable bound, so they work for every instantiation regardless of issue #515. That issue still matters for copying accessors; this proposal does not depend on it. Callable.Callable[[&K], Option[&V]]is accepted, with the origin fixed by the first reference-carrying parameter, as for&Vresults.
Layout and code generation
No runtime cost and no new representation. An Option[&T] is one 128-bit slot,
the same size as Option[i64] and &T:
| Value | Low word | High word |
|---|---|---|
&T | slot address | tag shift |
Some(r) | slot address | (shift << 1) | 1 |
Nothing | 0 | 0 |
Ok(r) in Result[&T, E] | slot address | shift << 1 |
Err(e) | e’s word | (e.tags << 1) | 1 |
This is the existing nested-tag scheme: wrap_tagged and tagged_payload
already restore a payload’s high word exactly, so construction is a shift and
an or, and a match tests bit 0. The shift needs at most 7 bits, so layout
depth accounting charges a reference payload 7 tag bits within the existing
64-bit tag budget.
A null-address niche (the Rust layout) is rejected: the slot is 128 bits either
way, so it saves nothing, and it would add a special case to every tag read
(sum_tag, Unwrap, lower_try, widen_sum). Values are not stored, so no
heap or owner-local storage is involved, and there is no cleanup: a
reference-carrying enum is never managed.
Diagnostics
Each rejection names the value, the place, and the fix:
| Case | Message |
|---|---|
| Stored in an aggregate | Option[&Item] carries a reference and cannot be stored in a list; match it and store an owned copy or an index |
| Class field or user enum declaration | references cannot be stored in class fields; Option[&T] and Result[&T, E] may only be returned or held in locals |
| No reference parameter | a function returning Option[&Item] needs a reference parameter to borrow from |
| Wrong origin | this Some borrows fallback, but Option[&Item] results borrow the first reference parameter, items |
&mut payload from a shared origin | Option[&mut V] requires the first reference parameter to be &mut |
Reference in Err | Result errors cannot carry references; copy the part of the input the error reports |
match &o | o already holds a reference; match o directly |
unwrap_or(&other) | unwrap_or cannot choose between two borrows; use match |
Across yield, into a scoped worker, into a closure | found holds a borrow of items and cannot cross yield / be sent to a worker / be captured; match it first and pass the reference |
| Temporary origin | lookup(...) borrows a temporary argument; bind the argument to a local first |
| Type argument | reference type arguments are not supported; write Option[&T] in the signature (existing message, reused) |
Teaching
- Look up a value that may be absent
replaces “Optional borrowed lookup is not implemented” with a short
dict.get_refsection:getcopies,get_refborrows, and the dictionary stays borrowed until the payload’s last use. Readers already know element borrows from the preceding lessons. - Return a reference to a field
gains “Return a reference that may be absent”, writing
findwithOption[&Item]and showing?on it. This is the first point where readers write their own returning functions with references. - The reference gains a page after returned references, “Optional and fallible references”, stating rules 1 to 4, the layout, and the rejected cases. Returned references and borrowing link to it instead of saying references never enter enums.
The rule to teach is one sentence: a lookup can return Option[&T]; treat
the result like the reference it holds, and never store it.
Standard library changes
Naming follows one rule across builtin and std containers (decided after this
proposal was accepted; see
copying and borrowed lookup):
indexing and plain accessors trap; get and peek return a copy in an
Option; get_ref, peek_ref, and get_mut/peek_mut return borrows.
| Module | Today | Proposed |
|---|---|---|
builtin dict | get(k) -> Option[V] copies non-affine values | Add get_ref(k) -> Option[&V] and get_mut(k) -> Option[&mut V] for every value type |
builtin list | get(i) -> Option[T] copies | Add get_ref(i) and get_mut(i); get_ref(0) and get_ref(-1) replace a separate first/last |
std.cache.lru | get_with, peek_with, trapping get_ref, peek_mut | Copying get(&mut self, k) -> Option[V] (promotes) and peek(k) -> Option[V], possible now that methods are checked only when used (issue #515); get_ref -> Option[&V] (promotes), get_mut -> Option[&mut V], peek_ref -> Option[&V], peek_mut -> Option[&mut V]; remove the callback and trapping forms (issue #516) |
std.graph | node/edge trap; callers test contains_node first | Keep trapping node/edge (handle indexing); add get_node_ref, get_node_mut, get_edge_ref, get_edge_mut |
std.collections | Heap.peek, HeapBy.peek trap when empty | peek_ref() -> Option[&T], and a copying peek() -> Option[T] for copyable T, matching pop() -> Option[T] |
std.deque | No access without removal | Add get_ref(i)/get_mut(i) if a use case appears |
std.http, std.http.cookie, std.http.body, std.json, std.toml | find(...) -> Option[i64] | find(...) -> Option[&Header] (and the matching element type); callers currently index immediately |
| pipelines | next, find, reduce reject &T items | Phase 3: return Option[&T] borrowing the pipeline’s source |
Not affected: regex Captures.get (copyable Match), env.get (owned
text), and toml.Document.get, whose arena nodes are not Toml values and
which therefore still builds a copy.
Std has no stability promise yet, so the replaced forms are removed in the same change rather than deprecated, keeping one obvious way.
Alternatives considered
| Alternative | Why rejected |
|---|---|
| Status quo with callbacks | Every container repeats the work; callbacks cannot ?, return or break; signatures gain R and F parameters |
| Membership test plus trapping getter | Two lookups per access; a forgotten test is a crash rather than a type error |
| Handles and indices | A second bounds check, no relation between index and collection, and impossible for private layouts and dictionaries |
A dedicated optional-reference type (&T? or Ref[T]) | New syntax and a type learners must map back to Option; match and ? already handle Option |
| Any enum carrying references | Needs reference fields in declarations or reference type arguments; see Which enums qualify |
| References in all aggregates with lifetime parameters | Breaks the no-annotation design goal and adds checker and learner cost far beyond these use cases. This proposal does not preclude a later B11 decision on stored references |
Returning Option[&T] but forbidding local bindings | Forces every lookup into a match scrutinee; ? could not bind its result |
Implementation plan
- Types and checking. Add a
carries_loanpredicate (references and reference-carrying enums) and use it where origin selection,return_origin,check_return_reference, trailing-expression checks andcall_reference_resultnow testTy::Ref. Admit reference payloads inOption/Resultresolution only for return types and local annotations; keep the storage barrier for every other position, with the messages above. Make?,unwrapandok_orforward the operand’s loan, and by-value matches create child payload loans. Charge 7 tag bits in layout depth. Tests: acceptance, every rejection row, loan ends per arm,&mutaliasing, temporaries, generic per-instance checking, and codegen of nestedResult[Option[&T], E]. - Builtins and docs.
dict.get_ref/get_mut,list.get_ref/get_mut; the new reference page; the two tutorial sections. - Std adoption.
std.cache.lru(issue #516),std.graph,Heap.peek, thefindfunctions, each with its reference page and tests. - Extensions.
mutreference-carrying locals, reference-carrying parameters, then pipelinenext/find/reduceover&Titems.
Each step is usable alone; step 1 with Option alone would already serve user
code.
Open questions
- Scope:
Optiononly, orOptionandResult? Recommendation: both, with nesting, because fallible borrowed lookups are common and the two share one implementation. - May
Errcarry a reference? Recommendation: no; seeErrpayloads. - Nesting
Result[Option[&T], E], includingOk(found)at a return. Recommendation: allow. It is still one value produced by one call, and rule 4 continues to reject every stored position. - Local construction (
found = Some(&xs[0])). Recommendation: allow in phase 1; it isr = &xs[0]plus a tag.mutreassignment waits for phase 2. - Reference-carrying parameters. Recommendation: allow in phase 2, with the parameter counting toward origin selection.
- Builtin names:
get_ref/get_mut, or changegetto borrow affine values? Recommendation: new names. One method whose result is a copy fori64and a borrow forlist[i64]would hide which one the reader has. Decided:get/peekcopy andget_ref/peek_ref/*_mutborrow, in every container, includingstd.cache.lru. - Trapping accessors: replace or keep beside
get_*? Recommendation: changeHeap.peekto returnOption; keepgraph.node/edgetrapping like indexing and addget_node_ref/get_edge_ref; remove the LRU callback and trapping getters. unwrap_orwith a reference default. Recommendation: reject, as conditional reference expressions are.