Finite error sets as anonymous sums
Status: Implemented within the agreed initial contract. General anonymous
sums have shipped, as described in the
anonymous-sum reference.
It adopts case cause: Type: at the case position, grouped types and inline
normalization/injection/widening, including Result widening and borrowed matching.
Human-readable reporting has also shipped.
Builtin Failure stays as a last resort, for when no precise error can be
written; issue #342 closed after removing the cases that forced erasure. The current
propagation reference
remains authoritative. Case guards, residual-set inference and empty sums are
deferred extensions in B14/B71 of the backlog, not missing
requirements of this completed initial contract. Human-readable presentation is specified separately
in formatting and error reports. The
composition overview separates this language feature
from explicit mapping and typed stream APIs.
Motivation and research
The proposed signature
Result[(), IoError | AllocError | SomeError] describes one error drawn from
a finite set. It does not mean simultaneous errors, a dynamically typed value,
or automatic recovery. This could remove wrapper-only enum declarations while
retaining payloads and compile-time coverage checks.
Dillon McMahon’s article argues for composing precise error sets, widening them when combining operations, and narrowing them after recovery. It also separates recovery type information from operational context. Those are useful design questions, not proof that one implementation is best for Plenty. The Missing Piece in Rust Error Handling
The eros documentation describes typed and erased errors, widening and
recovery. Crucially, version 0.8.1 documents boxed storage for ErrorUnion.
Its ergonomics therefore do not demonstrate allocation-free inline error
composition. Plenty should evaluate its own representation rather than copy
that storage choice. eros documentation
The reported mixed Reddit reaction is not used as evidence here: no particular thread was supplied or independently assessed. The following objections are design analysis, not attributed community consensus.
Agreed initial contract
The following notation describes the implemented composition contract:
def load() -> Result[Config, IoError | ParseError]:
source = read_source()?
parse_source(source)
A | B is a general type expression, available in ordinary value positions
as well as Result errors. Error composition supplies the first acceptance cases;
the type system must not restrict sums to the second argument of Result.
- Treat
A | Bas a tagged, owned sum of whole nominal types, not a C union and not an untagged overlap of values. No boxing is implicit. Storage must accommodate the largest payload and its tag; existing recursive-data rules still apply. Large payloads can enlarge every enclosingResult. - Normalize parentheses, order, duplicates, and resolved aliases:
A | BandB | Ahave the same identity;A | AisA. Preserve distinct nominal wrappers. Publish no stable tag-number/FFI-layout guarantee by accident. - Permit lossless injection of
Einto a destination containingE, and widening of a source set into a superset. Do not invoke arbitrary user conversions. Apply these rules in expected-type checking for bindings, assignments, arguments,Err, direct returns, and?. Also permitResult[T, E]to widen toResult[T, F]when E is a member/subset of F: success moves unchanged and failure is retagged without allocation. This does not establish covariance for arbitrary generic containers or references, and does not change the success type. - Reject propagation when any source alternative is absent. Diagnostics name
the missing alternatives and source operation. The separately specified
builtin
Failureerasure at?remains a deliberate last resort; it is not lossless sum widening. Issue #342 superseded the original removal plan. - Provide exhaustive, unambiguous matching by member type using
case cause: MemberType:. Existing enum constructor patterns cannot identify every class payload; existing pattern|keeps its separate meaning. - Do not implicitly flatten named enum variants.
HttpError | AllocErrormay still containHttpError.Allocation; deduplicating that semantic overlap requires an explicit classifier. Otherwise API abstraction is broken. - A first version can match the entire set without automatic residual-type
inference. Recovery that subtracts a member type is a further feature, not
free syntax sugar. An uninhabited empty set and conversion to infallible
Talso need a decision; do not use inhabited unit as an empty error type.
Function return types remain declared. Do not infer a public error set from the body or silently widen a declared binding after a later assignment. Expected types guide injection; inference must not guess between distinct generic substitutions that satisfy membership. Normalize again after concrete generic substitution, so a generic set can collapse to a single member.
Injection and widening evaluate the source once and preserve the active payload. Owned noncopyable payloads move; inactive alternatives are never initialized or dropped. Early exits and destruction follow the existing sum ownership rules. Copyability and thread-transfer eligibility require every member to qualify. Existing restrictions on sum payloads and recursive storage continue to apply; general sums do not independently introduce stored references.
Matching and remaining surface decisions
Matching must select a whole member type and bind its payload, including class
payloads. The implemented spelling is case cause: IoError: at the case
position. It does not extend existing pattern |. An enum member can then
be matched by its constructors normally.
The initial contract requires exhaustive coverage of normalized members, or a wildcard. Case guards remain unsupported; a future guarded arm alone cannot establish coverage. Reject duplicate unguarded member arms, including members that coincide after generic substitution. Specify those diagnostics on concrete specializations rather than pretending generic alternatives always remain distinct.
Owned matching follows ordinary move rules; matching through & or &mut
borrows the active payload with the corresponding permission and cannot let it
outlive the owner. A wildcard does not infer a smaller residual sum in this
version. Empty sums, automatic error-set subtraction, and automatic conversion
to infallible values are deferred.
The implementation puts | below references and type applications, uses (T)
for grouping and (T,) for singleton tuples, and allows typed-member binding
when normalization leaves an ordinary type. Callable brackets delimit their
argument/result types. Diagnostics cover these cases alongside generic inference
ambiguity; ambiguous constructors require an explicitly typed intermediate.
Replacing Failure
Replace uses of builtin Failure with precise errors or finite sums, optionally
named by ordinary type aliases. Keep domain enums where they express policy;
map_error remains the explicit way to classify or wrap a cause. Do not replace
erasure with an implicit catch-all member or mandatory reporting bound.
The original alternative was to remove the prelude builtin and its special
checking/code generation entirely, then reject old erasure uses. That alternative
was superseded by the issue #342 decision: retain Failure as a last resort and
remove the cases that force it. C exports now accept payload-free user error
enums; explicit retained-job errors and drop_job() preserve start/submission
causes without mandatory erasure. Examples, templates and diagnostics use precise
errors, except the benchmark template deliberately deferred during performance
comparisons. Tests of Failure itself remain intentional.
The mutual-TLS lesson and installed CLI/data and JSON-service workflows are acceptance cases for precise errors and reporting; see the qualification record. Retaining erasure is a documented policy, not unfinished builtin removal.
What sets do not solve
An I/O failure while reading and the same I/O type while writing become the
same set member. std.io.CopyError.Read versus .Write carries useful meaning
that a bare union discards. Use named operation wrappers or structured context.
Likewise, JSON offsets and partial byte counts must remain attached to causes.
A public set exposes its member types and changes when another alternative is added. Exhaustive callers then need updating. Named domain enums can provide a more stable abstraction boundary, though adding their variants also affects exhaustive matching. An erased error hides alternatives but sacrifices static coverage. Choose based on the caller’s recovery contract.
Unions do not automatically fix Reader returning StreamError: the stream
interface must still become generic or adopt a different envelope. Nor do they
replace allocation-aware context construction in std.error.Context[E].
Concrete evaluation targets
Try sets on internal composition near http/body.plenty’s read_json and
send_json, and the HTTP client’s framed/netted boundaries. Compare with
explicit mapping into the current named types. Preserve BodyError.status
classification, allocation normalization, JSON locations, and retry policy.
Do not start by replacing CopyError, Partial, JsonError, or every public
HTTP error. They carry semantics beyond membership. Require comparisons of
signature readability, diagnostics, representation size, compile time, and
client recovery code. Tests must cover aliases, duplicate types, permutation,
nested sets, ambiguous generic members, missing-member diagnostics, payload
destruction, exhaustive matching, and no implicit allocation.
Explicit mapping is the implemented baseline. Add ordinary non-error sum values to the acceptance cases, plus Result widening on both success and failure, generic normalization, borrowed/mutable matching, and entrypoint reporting of each active member. Builtin Failure and its erasure stay as a last resort, for when no precise error can be written (issue #342). Production typed-stream adoption subsequently shipped under the separate composition proposal: sums alone cannot restore causes already discarded by an adapter.