Why it is shaped this way
Relay's error directives in Swift's terms
Status: accepted, 2026-10-03. Serves Honest data and Relay's words. Reopen if a product needs a throwing read on fields that carry no directive, or if Swift gains typed throws broad enough to carry the field's error type through accessors.
Status: accepted, 2026-10-03. Serves Honest data and Relay's words. Reopen if a product needs a throwing read on fields that carry no directive, or if Swift gains typed throws broad enough to carry the field's error type through accessors.
Context#
Relay's reader has one way to say "this cannot be shown": throw during
render, and let an error boundary catch it. @required(action: THROW),
@throwOnFieldError and the @catch that stops them all lean on that. A
SwiftUI body cannot throw, and a lens accessor is read inside one. The
question was how each directive reads in Swift without inventing a word.
Decision#
@required(action: NONE | LOG)nulls the enclosing object in Relay; here the accessor that produces the enclosing lens returns nil. The compiler generates asatisfiedcheck on every lens whose required children can bubble, and every producer of such a lens (linked field, spread, list element, connection node) runs it with tracked reads. LOG reports the path throughEnvironment.requiredFieldMissing. A root that bubbles fails the operation:Phasehas no null data.@required(action: THROW)throws at the read: the field's own accessor isget throwsand raisesRequiredFieldError. Nothing else in the lens is affected.@catch(to: RESULT)is aResult<T, FieldErrors>accessor whose failure holds the field's error and everything below it;to: NULLis the plain optional accessor.@throwOnFieldErroron a fragment makes its spread accessorget throws; on an operation it puts the handle in.failed(FieldErrors)while the data stays in the store. Semantic non-null types follow Relay's rule: under@throwOnFieldErroror inside@catch.- Field errors are stored beside the field they name, resolved by path at ingest, and cleared by the next payload that answers the field. A plain accessor keeps reading an errored field as null.
Evidence#
- The delivery tests (2026-10-03): a
@catchread, a plain read and a cached read of the same errored field agree with the response; NONE drops a list element and nothing else; LOG reportsstatusonce; THROW throwsRequiredFieldErrorand the semantic field beside it readsString;@throwOnFieldErrorfails the operation on an uncaught error and not on a caught one. - The bench: a
@catchread of a field without an error costs 122 ns against 29 ns for a plain read (aResult, a closure and the error lookup); asatisfiedcheck of a lens with one required field costs 29 ns; twenty field errors add about a quarter of a millisecond to the ingest of the 686 KB fixture, most of it the index of entries the path walk needs. - The GitHub sample: a missing repository comes back as
nullwith a field error, andrepository @catchshows the server's sentence instead of a guess.
Not chosen#
- Throwing accessors everywhere, so that any error could surface at the
read: every body would be a
try, and the schema's nullability would stop meaning anything. Resulteverywhere: the same, in a different spelling.- Dropping THROW: it is Relay's word for "this view cannot render", and
get throwssays exactly that at exactly the read. - A null
Datafor a bubbling root, as Relay returns:Phase.ready(nil)has no meaning a view could render;.failednames the field.
Baton