Custom scalars

Teach the generator how a GraphQL custom scalar deserializes into a rich Ruby object (and serializes back when used as a variable). A field typed Money then generates const :price, T.nilable(Money) and casts with Money.parse(...) inline — no runtime reflection:

GraphWeaver.register_scalar("Money", Money, requires: "bigdecimal")

Registrations are global by default. A client scopes them: client.register_scalar(...) overlays the global registry for that client's generation only — so two servers can disagree about what a DateTime is, and neither leaks into the other.

Pass a real class as type: and the cast/serialize are inferred from it by probing the deserialize side and pairing its serializer:

the class defines cast serialize
.parse Type.parse(v) v.to_s
.load Type.load(v) Type.dump(v)

so the common case needs nothing more. Probing the deserialize side is deliberate — every object has #to_s, so inferring off it would wrongly wrap plain types like String/Integer; requiring a .parse/.load the type actually defines avoids that (and is why the built-in scalars — Date, ID, Int, and friends, pre-registered and detailed below — can be registered with their real class constants). Override explicitly when you need to:

  • a Symbol method name, nothing to misspell: cast: :loadMoney.load(expr), serialize: :to_jsonexpr.to_json
  • a Proc for anything a method name can't express: cast: ->(expr) { "Money.new(#{expr})" }
  • :itself to force pass-through, opting out of inference (rare)

type: also accepts a plain string ("BigDecimal") when you'd rather not reference the class. requires: (a string or array) names files emitted as requires atop the generated source so the cast/type resolve. When type: is a real class (so the runtime is loaded), each path is also required at registration — a typo fails now, not in the generated file.

Pass coerce: true to let a variable of this scalar accept either the value object or its raw input, normalizing the latter through the cast:

GraphWeaver.register_scalar("Money", Money, coerce: true)
# generated execute now takes T.any(Money, String); "12.00" is parsed
StoreQuery.execute(budget: "12.00")          # Money.parse("12.00") under the hood
StoreQuery.execute(budget: Money.new(1200))  # passed straight through

Bad input still explodes (the cast raises), so some safety survives; coercion needs both a cast and a serialize. Off by default — the strict typed kwarg is the norm.

coerce: also takes a Symbol naming a conversion method, for built-ins where a plain method is the whole story — coerce: :to_f makes a variable accept 5/"5" and .to_f it, sending a native number (not "5.0") on the wire. The convertible built-ins already know theirs (Float:to_f, Int:to_i, ID/String:to_s), so rather than opting in each, flip the default:

GraphWeaver.auto_coerce = true

Resolved lazily at generation time (set it any time before you generate), it gives convertible built-ins their conversion and any scalar with a full cast/serialize pair (Date, your Money) parse-style coercion; an explicit coerce: on a registration always wins. Boolean has no lossless one-method conversion, so it stays strict.

The built-in scalars (Date, ID, Int, …) are pre-registered through the same path (Date even carries its own require "date"), so a later register_scalar overrides them; GraphWeaver.reset_scalars! restores the defaults (reset_scalars!(coerce: true) restores them coercible) and clear_scalars! empties the registry. Register before generating — it's a codegen-time concern, baked into the emitted source.

Enums: map onto your own T::Enum

By default each generated module grows its own T::Enum per GraphQL enum — AddPetQuery::Species, SearchQuery::Result::...::Species, one per module that touches it. That's fine until your app has its own domain enum, and then the boundary shuffle starts:

# your domain already speaks PetKind — it's in your models, your
# ActiveRecord enum column, your case statements
class PetKind < T::Enum
  enums { Cat = new("cat"); Dog = new("dog") }
end

# without a mapping, every call site converts by hand, in both directions
kind = PetKind.deserialize(pet.species.serialize.downcase)      # response -> domain
AddPetQuery.execute!(species: kind.serialize.upcase)            # domain -> wire

Two enums for one concept, glue at every crossing, and each generated module has its own incompatible Species, so a pet from SearchQuery and a pet from AddPetQuery don't even compare. Register the mapping once and the seam disappears — generated code speaks your enum everywhere, casting wire values in and serializing members out:

GraphWeaver.register_enum("Species", PetKind)                # global
api.register_enums("Species" => PetKind, "Role" => Role)     # or per client, in bulk

pet.species                                   # => PetKind::Dog — compare, case, persist directly
pet.species == other_pet.species              # same type across every query
AddPetQuery.execute!(species: PetKind::Cat)   # or "CAT" — members and wire values both work

When to reach for it: the enum has a life outside the API — it's persisted, matched in business logic, or shared across queries. When not to bother: display-only values you read and forget; the per-module generated enums are self-contained and need zero setup.

The mapping is inferred by name ("CAT"PetKind::Cat, case/underscore-insensitive against each member's serialized value), so aligned enums need only the one line. When names diverge, map: pins the exceptions and merges over inference:

GraphWeaver.register_enum("Species", PetKind, map: { "FELINE" => PetKind::Cat })

Two safety properties do the real work:

  • Exhaustiveness at generation: every value the schema declares must resolve to a member, or generation fails naming the gaps (PetKind has no member for Species value(s) DOG — add them, pin with map:, or absorb with fallback:). Your enum drifting from the server's is caught at rake graph_weaver:generate, not in production.
  • fallback: for forward-compat: fallback: PetKind::Unknown makes casting absorb wire values the server added after you generated — responses keep flowing instead of raising. Inputs stay strict either way: a typo'd input is your bug, not drift.

The translation tables are emitted into the generated source (SPECIES_FROM_WIRE / SPECIES_TO_WIRE) — reviewable in the diff, no runtime registry. And because registration can be client-scoped, two servers with different ideas of "Species" can map onto different (or the same) domain enums without touching each other.

Type helpers: your logic on generated structs

Derived values (display names, emoji, predicates) belong next to the data but not in it — rewriting wire values on the way in destroys the raw truth. Register a plain module and every struct generated from that GraphQL type includes it, whatever query it appears in:

module PetHelpers
  def adult? = birthday && birthday < Date.today << 24
  def display_name = adult? ? "#{name} 🦴" : "#{name} 🐶"
end

GraphWeaver.register_type("Pet", PetHelpers)   # or api.register_type(...)

pet.display_name   # => "Shelby 🦴"
pet.name           # => "Shelby" — the wire value stays honest

Because the include is emitted into the generated source, srb tc checks the helpers against each query's actual selection — a helper that calls birthday on a query that never selected it is a static error, which doubles as selection-completeness checking. Registrations are additive (global plus client-scoped stack), and fakes/cassettes get the behavior automatically since it lives on the struct.

For quick decoration, build the mixin inline — the block is module_eval'd into a fresh module auto-named under GraphWeaver::TypeHelpers so generated files can reference it:

api.register_type("Pet") do
  def display_name = "#{name} 🐶"
end

Same runtime behavior, one caveat: srb tc can't see into block-defined methods, so prefer a named module where static checking matters — complexity on demand.