Why it is shaped this way
A mapped scalar is a fallible read
Status: accepted, 2026-10-04. Serves Honest data and Relay's words. Reopen if a mapping whose conversion cannot fail has to keep the schema's nullability, or if a product shows documents where an annotation on every mapped field costs more than it protects.
Status: accepted, 2026-10-04. Serves Honest data and Relay's words. Reopen if a mapping whose conversion cannot fail has to keep the schema's nullability, or if a product shows documents where an annotation on every mapped field costs more than it protects.
Context#
A custom scalar is stored as its text, exactly as the server wrote it, and
reads as a String. The first adopter asked for a mapping at the lens
(#11): customScalarTypes in
baton.json, Relay's key, so that Decimal reads as Foundation.Decimal,
DateTime as Date, Url as URL. The slot keeps the text and the
accessor converts.
The open question was what a non-null mapped field reads when the text
does not convert. As built, a non-null field whose value the generated
type cannot hold reads as the type's zero value and is reported: an empty
string, 0, false, an empty list. The issue says an amount that does
not parse must never render as 0. It asks for nil, on an accessor it
also wants non-optional. A mapped type has no honest zero either: zero is
an amount, a date's zero is a date, and a URL has none.
Decision#
Types follow what the schema promises. A non-null custom scalar promises that a text is there. It does not promise that the text converts to the type the client chose. So a mapped scalar is a fallible read, and its accessor says so. This binds the mapping when it is built (planned).
- A mapped scalar's accessor is optional, wherever the schema puts the
field, unless a directive says what a failure does. Under
@requiredit is non-optional, and a failure bubbles to the enclosing lens or throws. Inside@catchit is aResultwhose failure holds the error. Under@throwOnFieldErrorit is non-optional, and a failure throws at the fragment or fails the operation. This is the typing a field that can carry an error in place already has. - No mapped scalar reads as a zero value, and the mapping asks no type for one.
- A value that does not convert is reported as unexpected, never as missing: the heal would refetch it forever.
- A conversion that cannot fail keeps the schema's nullability. A generated enum with a case for a value this build does not know, as proposed with the same feature, stays non-optional on a non-null field.
- What the schema does promise is read as before. A null where it says non-null, or a value of another kind, reads as the type's zero value and is reported. Missing data reads as a zero value and is healed.
- Where a failure is detected is not decided: at the read, with the checks
behind
@requiredand@throwOnFieldErrorconverting too, or once at ingest, as a field error stored beside the field. The mapping's design and the read bench decide.
Evidence#
- The readers as built:
requiredString,requiredInt,requiredDouble,requiredBooland the list readers answer a null, a missing value and a value of another kind with"",0,0,falseand[], and report it. - The ingest as built: a custom scalar is kept as its text and nothing is
parsed. A built-in scalar's token of another kind fails its response
with an
IngestError, so the strict end of the rule exists already. - The terminology's Error behavior and Throw on field error: a field
that can carry an error in place is non-optional under
@throwOnFieldErrorand inside@catch, and optional elsewhere. The decision adds no rule; it places mapped scalars under this one. - Not measured yet: what a conversion costs at a read, which the issue asks the read bench to record, and what it costs again in a check.
Not chosen#
- A zero value and a report, the rule as built, for mapped types. Each
type would owe the mapping a zero,
URLhas none, and a zero amount looks like data. - The issue's shape as written: non-optional on a non-null field and nil on a failure. One accessor cannot be both.
- Failing the whole response when one value does not convert: one bad URL in a feed would fail the feed. How far a field's failure reaches belongs to the directives.
- Trapping, which the principle refuses.
- A wrapper that holds the value or the raw text:
@catchalready gives aResult. - Non-optional accessors that trust the server to send what converts: the amount that does not parse has no answer then.
Baton