mochi/payload

A reference implementation of the structured mutation payload pattern.

Inspired by absinthe_error_payload by Mirego.

GraphQL has two ways to signal failure from a mutation:

  1. Top-level errors — raised as Error(msg) from a resolver. These are for unexpected failures (bugs, auth errors, infrastructure problems).

  2. Payload errors — returned as DATA inside the response. These are for predictable validation failures the client should handle (e.g. “email already taken”). The client can pattern-match on successful and inspect messages without treating them as exceptions.

This module ships a ready-to-use MutationPayload(result) type. It is a reference implementation — copy or adapt it freely. Your own payload type can have a completely different shape (e.g. a union, an errors-only field, or domain-specific codes). The only requirement mochi has is that your resolver returns something your encoder can turn into a Dynamic.

Quick start

import mochi/payload
import mochi/query
import mochi/schema

fn create_user_resolver(name: String, _ctx) {
  case validate_name(name) {
    Ok(user) -> payload.ok(user)
    Error(_) ->
      payload.error([
        payload.message_for("name", "has already been taken")
        |> payload.with_code("already_taken"),
      ])
  }
}

let #(create_user_payload, vm_type) = payload.payload_types("CreateUser", "User")

query.new()
  |> query.add_type(vm_type)              // shared — register once per schema
  |> query.add_type(create_user_payload)  // one per mutation
  |> query.add_mutation(
    query.mutation(
      name: "createUser",
      args: [query.arg("name", schema.non_null(schema.string_type()))],
      returns: schema.named_type("CreateUserPayload"),
      decode: fn(args) { query.get_string(args, "name") },
      resolve: fn(name, ctx) { Ok(create_user_resolver(name, ctx)) },
      encode: fn(p) { payload.to_dynamic(p, user_to_dynamic) },
    )
  )

Building a custom payload

You are not required to use this module. Any type that can be encoded to Dynamic works as a mutation result. A minimal custom example:

pub type MyPayload(a) {
  Success(data: a)
  Failure(errors: List(String))
}

fn my_payload_to_dynamic(p: MyPayload(a), encode: fn(a) -> Dynamic) -> Dynamic {
  case p {
    Success(data) ->
      types.record([
        types.field("ok", True),
        types.field("data", encode(data)),
      ])
    Failure(errors) ->
      types.record([
        types.field("ok", False),
        types.field("errors", types.to_dynamic(errors)),
      ])
  }
}

Register the matching GraphQL type with schema.object(...) and query.add_type(...) the same way you would any other type.

Types

Standard mutation response envelope.

pub type MutationPayload(result) {
  MutationPayload(
    successful: Bool,
    result: option.Option(result),
    messages: List(ValidationMessage),
  )
}

Constructors

A validation error message associated with a mutation field.

pub type ValidationMessage {
  ValidationMessage(
    field: option.Option(String),
    message: String,
    code: option.Option(String),
  )
}

Constructors

Values

pub fn error(
  messages: List(ValidationMessage),
) -> MutationPayload(a)

Build a failed payload with validation messages and no result.

pub fn message(msg: String) -> ValidationMessage

Create a top-level (non-field-specific) validation message.

pub fn message_for(
  field: String,
  msg: String,
) -> ValidationMessage

Create a field-specific validation message.

pub fn ok(result: a) -> MutationPayload(a)

Build a successful payload wrapping the given result.

pub fn payload_type(
  name: String,
  result_type_name: String,
) -> schema.ObjectType

Create a payload type for a specific mutation result type.

name is used to build <name>Payload (e.g. "CreateUser""CreateUserPayload"). result_type_name is the GraphQL type name of the success result.

let user_payload = payload.payload_type("CreateUser", "User")
// Produces CreateUserPayload { successful, messages, result }
pub fn payload_types(
  name: String,
  result_type_name: String,
) -> #(schema.ObjectType, schema.ObjectType)

Create both the payload type and the shared ValidationMessage type at once.

Returns #(payload_type, validation_message_type). Register validation_message_type once per schema even if you have multiple mutations.

let #(create_user_payload, vm_type) = payload.payload_types("CreateUser", "User")

query.new()
  |> query.add_type(vm_type)
  |> query.add_type(create_user_payload)
pub fn to_dynamic(
  payload: MutationPayload(a),
  encode_result: fn(a) -> dynamic.Dynamic,
) -> dynamic.Dynamic

Encode a MutationPayload to Dynamic for the GraphQL response.

Pass the encoder for the inner result type:

payload.to_dynamic(p, user_to_dynamic)
pub fn validation_message_to_dynamic(
  vm: ValidationMessage,
) -> dynamic.Dynamic

Encode a ValidationMessage to Dynamic for the GraphQL response.

pub fn validation_message_type() -> schema.ObjectType

The shared ValidationMessage GraphQL object type.

Register this once per schema:

query.new()
  |> query.add_type(payload.validation_message_type())
pub fn with_code(
  vm: ValidationMessage,
  code: String,
) -> ValidationMessage

Attach a machine-readable error code to a validation message.

Search Document