ADR-0070: Canonical Result(T, E)
Status
Implemented (with two phases deferred — see "Deferred from v1" below).
Summary
Introduce Result(T, E) = enum { Ok(T), Err(E) } as a canonical, prelude-injected generic enum, paralleling Option(T) from ADR-0065. The infrastructure is already in place — comptime-generic enum monomorphization (ADR-0025), enum data variants (ADR-0037), exhaustive pattern matching (ADR-0052), Clone propagation (ADR-0065), and linearity propagation through enums (ADR-0067) — so this ADR is primarily a registration and method-surface layer, not a new compiler-machinery layer. v1 ships a minimal method set (is_ok, is_err, ok, err, unwrap, unwrap_err, unwrap_or, plus expect / expect_err); higher-order combinators (map, map_err, and_then, or_else) wait until the comptime-generic anon-function shape stabilizes (the same gating reason Option::map was deferred in ADR-0065). The ? operator and From-style error conversion are explicitly out of scope — they're a separate ADR with their own design questions. Linear element types follow the same protocol ADR-0067 established for Option(T:Linear): linearity propagates, unwrap is rejected, users must match exhaustively.
Context
Why now
Three forcing functions pile on at once:
- ADR-0072 needs
Resultfor its conversion APIs.String::from_utf8(v: Vec(u8))should return the originalVec(u8)on failure so the caller can recover or report; today it would have to returnOption(String)and discardv. ADR-0072 explicitly flags this as a v1 limitation pendingResult. WithoutResult, every fallible-with-recovery API in the language has the same hole. - ADR-0071 (
char::from_u32) flagged the same hole. AResult(char, u32)is a strict improvement overOption(char)for diagnostics — preserving the offending input is what makes a useful error. - The infrastructure is ready. ADR-0065 demonstrated the canonical-prelude-enum pattern (
Option); ADR-0067 extended it to handle linearity propagation.Resultis the obvious second canonical sum type. Building it now means the?operator follow-up (which is a substantial addition) has a stable canonical type to desugar against.
Without Result, every caller that wants "succeed-with-payload, fail-with-context" defines a one-off enum. This fragments the ecosystem the same way ad-hoc Option-equivalents did before ADR-0065.
What's already there
Option(T)(ADR-0065) — the precedent. Canonical name, registered via the prelude source string injected atFileId::PRELUDE. Methods (is_some,unwrap, etc.) live in the prelude alongside the type. Pattern-matches via standard ADR-0037 enum machinery.- Comptime-generic enums (ADR-0025 / ADR-0039) —
fn Result(comptime T: type, comptime E: type) -> type { enum { Ok(T), Err(E) } }is already expressible. - Linearity propagation through enums (ADR-0067) —
Option(T:Linear)reports as linear; same recursion handlesResult(T:Linear, E),Result(T, E:Linear), and both linear. - Niche optimization through layout (ADR-0069) —
Result(T, E)automatically benefits whenTorEcarries niches (e.g.,Result(char, char)will be 4 bytes once char's niches are registered).
What this ADR does not attempt
- The
?operator. Desugaringexpr?into amatch expr { Ok(x) => x, Err(e) => return Err(e) }is the easy part; making it interoperable with different error types requires aFrom-style conversion mechanism (Rust's?callsFrom::from(e)). Without that,?only works when error types match exactly — useful but limiting. Building?in the same ADR asResultitself bundles two design questions (sum type, error conversion) that are better separated. Follow-up: ADR-007X for?+ error conversion. From-style error conversion / interfaces. Out of scope. Also a follow-up.map,map_err,and_then,or_else. ADR-0065's Phase 5 deferredOption::mapbecause the comptime-generic anon-function path requires bothTand the return-type parameter to expressOption(U)fromf: T -> U. The same gating applies here. Once that path lands (single shared follow-up),map/map_err/etc. arrive simultaneously on bothOptionandResult.Result(T:Linear, E)andResult(T, E:Linear)ergonomics. Linearity propagates correctly (the recursion from ADR-0067 already covers it), but methods likeunwrapandunwrap_errare rejected for linear payloads (panic path leaks). v1 leaves users tomatchexhaustively — same posture as ADR-0067 took forOption(T:Linear). Adisposemechanism forResultis not meaningful (both arms always have a payload), so there's nothing to design here.try_*collection methods. APIs likeVec(T)::try_pushwould naturally returnResult, but adding them to existing collections is out of scope here. OnceResultis canonical, those APIs can be added incrementally without needing another ADR for the type.
Where Gruel lands
- Rust:
Result<T, E>with rich method surface,?operator,From-conversion. Gruel's destination is similar; this ADR ships the type and a smaller method surface, deferring?and conversion. - Swift:
Result<Success, Failure: Error>constrainsFailureto anErrorprotocol. Gruel doesn't constrainE— any type works. (No protocol exists today; if one's added later, it can be opt-in for?interop, not forResultitself.) - OCaml / Haskell:
result/Either. Same shape. Gruel matches the ML/Rust side. - Go: multiple return values, no
Result. We deliberately don't follow Go's path — exhaustive pattern matching is more useful than the discipline of "always checkerr != nil."
Decision
1. The type
A canonical generic enum, defined in the prelude:
fn Result(comptime T: type, comptime E: type) -> type {
enum {
Ok(T),
Err(E),
}
}
Registered alongside Option via the same prelude-injection mechanism (parsed first under FileId::PRELUDE). Users write Result(i32, String) anywhere a type is expected, no import needed.
2. Layout
Standard ADR-0037 enum-with-data: tag + payload union sized to the larger of T and E, plus padding for alignment.
When T or E carries niches (per ADR-0069), the niche-filling pass elides the discriminant byte. Examples:
Result(bool, bool)— both arms are 1-byte-with-niche; result is 1 byte.Result(char, ())— char's niches absorb theErrvariant; 4 bytes, no tag.Result(i32, ())— i32 has no niches; standard layout (8 bytes: tag + i32 + padding).Result(char, char)— char's niches accommodate the discriminant; 4 bytes.
No special-case logic; it falls out of ADR-0069's existing infrastructure.
3. Method surface (v1)
| Method | Receiver | Signature | Notes |
|---|---|---|---|
is_ok | &self | (&self) -> bool | true iff Ok |
is_err | &self | (&self) -> bool | true iff Err |
ok | self | (self) -> Option(T) | Ok(t) -> Some(t); Err(_) -> None (drops E) |
err | self | (self) -> Option(E) | Ok(_) -> None (drops T); Err(e) -> Some(e) |
unwrap | self | (self) -> T | panic if Err; move t out otherwise. Requires T: !Linear and E: !Linear. |
unwrap_err | self | (self) -> E | panic if Ok. Same linearity restrictions. |
unwrap_or | self | (self, default: T) -> T | default consumed only on Err. |
expect | self | (self, msg: String) -> T | panic with msg if Err. |
expect_err | self | (self, msg: String) -> E | panic with msg if Ok. |
Panic messages for unwrap / unwrap_err: fixed strings ("called unwrapon anErr value" / "called unwrap_erron anOk value") routed through the existing panic infrastructure.
map, map_err, and_then, or_else ship in the same follow-up that lifts Option::map, since they share the same comptime-generic anon-function constraint.
4. Linearity
Same protocol as Option (ADR-0067):
- Linearity propagates.
Result(T, E)is linear iffT: Linear∨E: Linear∨ the generic-recursion machinery flags it. The existing recursion inis_type_linearhandles enums with payloads. unwrap/unwrap_err/unwrap_or/expect/expect_errare rejected when either payload is linear. The panic path mid-unwrapwould leak the other variant's linear payload (the one we panic instead of returning). Users mustmatchexhaustively.is_ok/is_errwork for anyT, E(they take&self, no consumption).ok/errwork as long as the dropped arm is non-linear. Dropping theErrpayload inr.ok()requiresE: !Linear; symmetric forerr(). Sema enforces this with a clear error.- No
dispose. UnlikeOption(T)::dispose(which is meaningful when the variant isNone, i.e., no live linear payload),Result(T, E)always has a live payload. There is no "empty" state to dispose. The right answer ismatch.
This means linear-payload Result types are ergonomically thin in v1 (only is_ok/is_err and conditional ok/err). That's deliberate — a richer story for linear sum types is a separate follow-up.
5. Clone conformance
Result(T, E) is Clone iff T: Clone and E: Clone. Synthesized at registration time via the same @derive(Clone)-equivalent path used for Option(T) (ADR-0065).
Since v1 enums are uniformly Copy (ADR-0065 §3.8:2 simplification), Result(T, E) is automatically Copy (and therefore Clone) when both T and E are Copy. The hand-written enum-clone synthesis kicks in once that simplification is refined.
6. Pattern matching
Falls out of ADR-0037 / ADR-0049 / ADR-0052 with no additions:
let r: Result(i32, String) = Result::Ok(42);
match r {
Result::Ok(n) => use(n),
Result::Err(e) => report(e),
}
Exhaustiveness checking already covers the two-variant case. No new matching machinery.
Open detail: should Ok and Err be importable as bare names (Ok(42) instead of Result(i32, String)::Ok(42))? Option's Some and None are bare — see ADR-0065 §"Migration." Mirror that: Ok and Err are bare-importable from the prelude. Same well-known-name registry.
7. Compiler integration
gruel-builtins/ prelude: add theResult(T, E)definition and v1 method bodies to the prelude source string injected underFileId::PRELUDE. AddOkandErrto the bare-importable name registry alongsideSome/None.gruel-air: no new infrastructure. Sema resolvesResultthrough the prelude exactly as it resolvesOption. Linearity propagation already handles enum payloads. Theunwrap/ok/errlinearity gates use the existingis_type_linearquery.gruel-codegen-llvm: no changes. Falls out of ADR-0037 enum codegen and ADR-0069 niche-filling.- Spec: new section
3.10 The Result(T, E) type(or wherever the prelude appendix sits), parallel toOption(T)'s section. Documents validity, layout (refers to ADR-0037 + ADR-0069), method surface, linearity rules.
Implementation Phases
- Phase 1: Preview gate + prelude scaffolding
- Add
PreviewFeature::ResultTypetogruel-error. - Append
Result(T, E)definition and a stub method body (is_okonly) to the prelude source string. - Register
OkandErras bare-importable names. Implementation note: matching ADR-0065 / Option's pattern, this isn't bare-imported; users writeR::Ok/R::Errafterlet R = Result(T, E). Same posture asO::Some/O::None. - Confirm name resolution and basic match work. Spec tests in
crates/gruel-spec/cases/types/result.toml.
- Add
- Phase 2: Core method surface
- Implement
is_ok,is_err,unwrap,unwrap_err,unwrap_orin the prelude. unwrap/unwrap_errlinearity gates (mirrorsOption::unwrap). Implementation note: matching ADR-0067's posture forOption(T:Linear), no explicit gate is added — the prelude method's body fails to typecheck under linear T or E (the discard patternSelf::Err(_)against a linear payload). v1 leaves users tomatchexhaustively at the use site for linear payloads. Phase 5 documents this.- Spec tests for each method, including panic behavior. Spec tests in
crates/gruel-spec/cases/types/result_methods.toml.
- Implement
- Phase 3: Conversions to Option — deferred. Blocked on the same infrastructure gap that deferred
Option::mapin ADR-0065 Phase 5: expressingOption(T)inside a generic method body requires either (a) parser support forOption(T)::Variant(...)in expression position (today's parser treatsOption(T)as a call statement and rejects the trailing::Variant), or (b) sema treating the receiver's boundTas comptime when used inlet O = Option(T). Both are real follow-up work. ADR-0065 cleared the same hurdle formap; once that lands,ok/errship simultaneously. Until then, users convert viamatch r { R::Ok(x) => O::Some(x), R::Err(_) => O::None }at the use site. - Phase 4:
expect/expect_err- Implement using the existing panic-with-message infrastructure.
@panic(msg)already accepts aStringparameter (codegen extracts ptr/len and calls__gruel_panic). - Spec tests. Added to
result_methods.toml.
- Implement using the existing panic-with-message infrastructure.
- Phase 5: Linearity propagation tests
- Verify
Result(MustUse, i32),Result(i32, MustUse),Result(MustUse, MustUse)all report as linear. Confirmed: the existingis_type_linearrecursion through enum payloads handles this transparently. - Verify the rejection diagnostics for
unwrap/ok/erron linear arms. In v1, instantiation itself fails — the borrow-selfmethods (is_ok,is_err) discard-pattern against linear payloads, which the borrow checker rejects. Same deferred limitation asOption(T:Linear)per ADR-0067 Phase 3. Tests inresult_linearity.tomldocument the current behavior; spec paragraph3.13:5records the deferral. - No new code expected — existing recursion should cover it; phase exists to confirm and document.
- Verify
- Phase 6: Clone conformance
- Verify
Result(i32, i32)isCopy(henceClone) under the v1 enum-Copy simplification. Spec testresult_conforms_to_cloneinresult_methods.toml. - Add a deferred-synthesis note for when ADR-0065's simplification is refined. Spec paragraph
3.13:6.
- Verify
- Phase 7: Niche optimization tests
Result(bool, bool)is 1 byte;Result(char, ())is 4 bytes (after ADR-0071 lands).Result(bool, bool)andResult(i32, Color)round-trip tests inresult_niches.toml. TheResult(char, ())case is gated on ADR-0071 and lives there.- No new code; verify ADR-0069's niche-filling consumes Result's discriminant correctly. Confirmed —
Result(bool, bool)round-trips four discriminant×payload combinations through both arms.
- Phase 8: Spec
- Write spec section 3.10 (or place under existing prelude appendix). Created
docs/spec/src/03-types/13-result-type.md(section 3.13, since 3.10 was already mutable-strings). Six paragraphs covering registration, layout, methods, linearity propagation, the v1 linear-payload limitation, and Clone conformance. ADR frontmatterspec-sectionsupdated to["3.13"]. - Cross-link from
Option(T)'s section. Added pointer in §3.8 after the Option methods list.
- Write spec section 3.10 (or place under existing prelude appendix). Created
- Phase 9: Stabilize
- Remove preview gate. Removed
preview = "result_type"andpreview_should_pass = truefrom spec tests; removedPreviewFeature::ResultTypevariant fromgruel-util/src/error.rs. - Update consumer ADRs (ADR-0072's
from_utf8return type; ADR-0071'schar::from_u32). Both ADRs were authored with the Result-returning shape from the start; no edits needed.
- Remove preview gate. Removed
Deferred from v1
- Phase 3 (
ok/errconversions toOption). Blocked on the same infrastructure gap that deferredOption::mapin ADR-0065 Phase 5: the prelude method body cannot constructOption(T)whenTis the receiver's bound generic parameter, because (a) the parser doesn't acceptOption(T)::Variant(...)in expression position and treatsOption(T)as a call statement requiring a semicolon, and (b) sema treatsTas runtime in the method body, solet O = Option(T)errors with "comptime parameter requires a compile-time known value." When ADR-0065's follow-up resolves this formap,ok/errship simultaneously. Until then, users convert via inlinematch. - Phase 5 (linear-payload prelude support). Linearity propagates correctly through
Result(T, E)(theis_type_linearrecursion handles enum payloads), but the prelude methodsis_ok/is_erruseborrow selfwith discard patterns (Self::Ok(_),Self::Err(_)). The borrow checker rejects the discard pattern against a linear payload — even though_consumes nothing — soResult(MustUse, _)cannot be instantiated through the prelude. Same deferred limitation asOption(T:Linear)per ADR-0067 Phase 3. Spec paragraph 3.13:5 records the gap; tests inresult_linearity.tomldocument the current behavior. A future ADR (smarter discard-pattern handling on borrowed enums, or per-Tmethod gating) lifts this for bothOptionandResulttogether.
Consequences
Positive
- Canonical fallible-with-context return type. Eliminates ad-hoc per-call-site enums.
String::from_utf8(ADR-0072) can return the originalVec(u8)on failure — the open question in that ADR resolves cleanly.char::from_u32(ADR-0071) gains the option of returning the offendingu32for diagnostics.- Niche-optimized layouts come for free via ADR-0069.
- Linearity story is consistent with
Option's — no new design surface. - Foundation for the
?operator follow-up.
Negative
- v1 method surface is small.
map,map_err,and_then,or_elsematter for ergonomic chaining and are deferred. Mitigated by the explicit pattern-match path always being available. - No
?operator yet means error propagation is verbose (matchat every layer). This is the v1 cost; the follow-up resolves it. - Linear-payload
Resultis even thinner than non-linear (onlyis_ok/is_errand conditionalok/err). Acceptable for v1 — the use case is rare. - Adding
Ok/Errto the bare-importable name space commits two short, common identifiers globally. Anyone wantinglet Ok = ...as a variable name has a problem. Mitigation: the canonical-name registry already commitsSome,None,String, etc.;Ok/Errare in keeping.
Open Questions
- Bare-import
Ok/Errvs qualifiedResult::Ok/Result::Err? Match what was done forOptionin ADR-0065. - Should
unwrap_ortakedefault: Tby value (consume) or by closure (|| -> T)? ADR-0065'sOption::unwrap_orconsumes. Match that for symmetry. The lazy form (unwrap_or_else) waits for the same anon-function follow-up that gatesmap. - Should
expecttakemsg: String(owned) ormsg: &str(borrowed)? Borrowed slices aren't a stable type yet; pass ownedStringfor v1. Migrate to&strwhen borrowed slices land.
Future Work
?operator +From-style error conversion — separate ADR. The big ergonomics win.map,map_err,and_then,or_else— ship together withOption::maponce the comptime-generic anon-function path is stable. Single follow-up ADR.try_*collection methods (try_push,try_reserve) — incremental additions to existing collections.Result(T, E)for linear types — richer methods (e.g., amatch-like "consume both arms" helper) if the ergonomic gap proves real.
References
- ADR-0025: Comptime generics.
- ADR-0037: Enum data variants.
- ADR-0049 / ADR-0052: Pattern matching.
- ADR-0065: Clone interface and canonical Option(T).
- ADR-0067: Linear types in containers.
- ADR-0069: Layout abstraction and niche-filling.
- ADR-0072: String / Vec(u8) relationship.
- ADR-0071: char type.
- Rust's
Result<T, E>and?operator documentation.