mochi/decoders
Small ergonomic helpers for the decoder callback that
mochi/types.{build} requires on every object type.
mochi round-trips field values through Dynamic during query
execution, which means each ObjectType needs a
fn(Dynamic) -> Result(t, String) decoder. Writing those by hand
produces a lot of mirror-image boilerplate — list fields with
per-item decoding, optional fields with sensible defaults, and the
standard case decode.run { Ok -> Ok; Error -> Error("Failed to decode <Type>") } wrap.
These helpers exist for one reason: to make a typical schema’s
build callback short and readable without giving up any of
gleam_stdlib’s decode typing. They’re additive — every
existing decoder keeps working unchanged.
All helpers follow gleam_stdlib’s decode continuation-passing
convention so they compose with use bindings:
use email <- md.optional_string("email")
Output decoders only. The
optional_*helpers conflate “field absent” with “field present but defaulted.” That’s correct formochi/types.{build}callbacks (mochi only invokes them on schema-conforming output values) but wrong for input validation — never use these to decode mutation arguments where “user sent nothing” must differ from “user sent an empty value.” For input validation, usegleam_stdlib’sdecode.field/decode.optional_fielddirectly so you control the default.
Example
import gleam/dynamic/decode
import mochi/decoders as md
import mochi/schema
import mochi/types
pub fn user_type() -> schema.ObjectType {
types.object("User")
|> types.id("id", fn(u: User) { u.id })
|> types.string("email", fn(u: User) { u.email })
|> types.int("age", fn(u: User) { u.age })
|> types.list_object("friends", "User", fn(u) { ... })
|> types.build(decode_user)
}
fn decode_user(dyn) {
let decoder = {
use id <- decode.field("id", decode.string)
use email <- md.optional_string("email")
use age <- md.optional_int("age")
use friends <- md.list_filtering("friends", decode_user)
decode.success(User(id:, email:, age:, friends:))
}
md.build_with(decoder, "User", dyn)
}
Values
pub fn build_with(
decoder: decode.Decoder(t),
type_name: String,
dyn: dynamic.Dynamic,
) -> Result(t, String)
Run a decode.Decoder(t) against dyn, returning the
Result(t, String) shape that mochi/types.{build} expects.
type_name is interpolated into the error message — keep it the
same as the GraphQL object type (e.g. "CapabilityGraph") so
error logs are searchable.
On failure, the first decoder error is included in the message so debugging doesn’t degrade to “something failed somewhere in this 14-field record”:
"Failed to decode User: expected String at name, got Int"
Collapses the standard four-line wrap:
case decode.run(dyn, decoder) {
Ok(v) -> Ok(v)
Error(_) -> Error("Failed to decode CapabilityGraph")
}
into a single call:
build_with(decoder, "CapabilityGraph", dyn)
pub fn list_filtering(
name: String,
item: fn(dynamic.Dynamic) -> Result(t, String),
next: fn(List(t)) -> decode.Decoder(final),
) -> decode.Decoder(final)
Decode an optional list field where each item is a Dynamic that
must be passed through a per-item decoder. Items that fail their
per-item decode are silently dropped; missing or non-list
values resolve to the empty list.
Useful for top-level “list of object” GraphQL fields where one malformed row shouldn’t kill the whole response. Behavior:
- Field absent or null →
[] - Field present, item ok → included in result
- Field present, item decode fails → dropped silently
The item callback is the same shape as a mochi/types.{build}
callback (fn(Dynamic) -> Result(t, String)), so existing
per-type decoders can be passed directly:
use nodes <- list_filtering("nodes", decode_node)
use edges <- list_filtering("edges", decode_edge)
Replaces the four-line decode.optional_field + list.filter_map
pattern with a single use-binding.
pub fn optional_bool(
name: String,
next: fn(Bool) -> decode.Decoder(final),
) -> decode.Decoder(final)
Decode an optional bool field, defaulting to False when absent.
Output decoder only — see the module-level note.
pub fn optional_int(
name: String,
next: fn(Int) -> decode.Decoder(final),
) -> decode.Decoder(final)
Decode an optional integer field, defaulting to 0 when absent.
Output decoder only — see the module-level note.
pub fn optional_string(
name: String,
next: fn(String) -> decode.Decoder(final),
) -> decode.Decoder(final)
Decode an optional string field, defaulting to the empty string
when absent. Continuation-passing form for use-binding:
use email <- md.optional_string("email")
Equivalent to decode.optional_field(name, "", decode.string, next)
but reads better at call sites that have many such fields.
Output decoder only — see the module-level note. Don’t use this for input validation where “absent” must differ from “empty”.