The Problem
When consuming GraphQL APIs from Elixir, you typically have two choices: hand-craft HTTP requests with raw strings, or use a library like Neuron that still treats queries as opaque strings at runtime. Both approaches share the same fundamental issue — you won't know your query is invalid until it hits the server.
Consider this:
# A typo in the field name? You'll find out at runtime.
query = """
query {
user(login: "octocat") {
nmae
bio
}
}
"""
Req.post!("https://api.github.com/graphql", json: %{query: query})
The field nmae doesn't exist — but nothing stops you from compiling and deploying this code. The error only surfaces when this code path executes in production. On top of that, the response is an untyped map, so you're left navigating nested Map.get/2 calls with no guarantees about the shape of the data.
I wanted something better: catch query errors at compile time, and get typed responses for free. That's why I built Grephql.
What Grephql Does
Grephql is a compile-time GraphQL client for Elixir. It parses and validates your GraphQL queries against a schema during mix compile, then generates typed Ecto embedded schemas for both responses and variables. At runtime, it simply sends the pre-validated query string over HTTP via Req and decodes the response into typed structs.
Here's what using it looks like:
defmodule MyApp.GitHub do
use Grephql,
otp_app: :my_app,
source: "priv/schemas/github.json",
endpoint: "https://api.github.com/graphql"
defgql :get_user, ~GQL"""
query GetUser($login: String!) {
user(login: $login) {
name
bio
avatarUrl
}
}
"""
end
That's it. At compile time, Grephql will:
- Parse the query and validate it against your GraphQL schema
- Generate
MyApp.GitHub.GetUser.Result— an Ecto embedded schema matching the query's selection set - Generate
MyApp.GitHub.GetUser.Variables— a struct with changeset-based validation for the$loginvariable - Define a
get_user/2function with proper@specand@doc
If you misspell a field name, reference a non-existent type, or pass a wrong variable type, you get a compile error — not a runtime surprise.
{:ok, result} = MyApp.GitHub.get_user(%{login: "octocat"})
result.data.user.name #=> "The Octocat"
result.data.user.bio #=> "..."
How It Works
The compilation pipeline has four stages:
1. Parsing
The lexer is NimbleParsec-based, and the parser uses an Erlang yecc grammar file (grephql_parser.yrl). Both are adapted from Absinthe's lexer and parser (MIT licensed). Absinthe already has a battle-tested, spec-compliant GraphQL parser — forking it let me focus on the compile-time validation and type generation layers instead of reinventing document parsing from scratch. Huge thanks to the Absinthe team for their excellent work. The parser outputs a complete AST with source location information for accurate error reporting.
2. Validation
Nine validation rules run against the AST, closely following the GraphQL spec:
- Fields — does this field exist on the parent type?
- Arguments — are required arguments present? Do types match?
- Variables — are variable names unique? Are they actually used?
- Fragments — do type conditions match? Are there cycles?
- Directives, Input Objects, Values, Operations, and Deprecation warnings
All rules share a unified AST traversal (Traversal.traverse_all/4) that walks both operations and fragments in a single pass.
3. Type Generation
This is where the magic happens. For each query, Grephql generates two sets of modules:
Output types — per-query Ecto embedded schemas following the naming convention Client.FunctionName.Result.Field.NestedField. Union and interface types use a parameterized Grephql.Types.Union Ecto type that dispatches on the __typename field from the JSON response.
Input types — schema-level modules under Client.Inputs.TypeName with a build/1 function that validates input through Ecto changesets. Variables get their own module Client.FunctionName.Variables with field-level validation.
4. Code Generation
The defgql macro ties everything together. It defines a function that:
- Builds and validates variables via
Variables.build/1 - Calls
Grephql.execute/3which sends the query viaReq.post!/2 - Decodes the JSON response into typed structs via
Ecto.embedded_load/3
The generated function includes a proper @spec and @doc with a variable reference table and links to generated modules.
Key Design Decisions
Ecto for Type Mapping
Using Ecto's type system was a deliberate choice. Ecto's embedded_load/3 handles the JSON-to-struct conversion naturally, and changesets provide excellent validation errors for variables. Custom scalar types (like DateTime) are just Ecto.Type implementations, which most Elixir developers already know how to write.
Req for HTTP
Req provides a composable middleware system, retry logic, and — crucially — Req.Test for testing without hitting real APIs. This means you get the full power of Req's plugin ecosystem while keeping your GraphQL client lean.
Fragments as First-class Citizens
The deffragment macro lets you define reusable fragments that are resolved at compile time:
deffragment :user_fields, ~GQL"""
fragment UserFields on User {
name
email
createdAt
}
"""
defgql :get_user, ~GQL"""
query GetUser($login: String!) {
user(login: $login) {
...UserFields
bio
}
}
"""
Fragment dependencies are automatically resolved and appended to the query document before sending.
Getting Started
Installation
Add grephql to your mix.exs:
def deps do
[
{:grephql, "~> 0.3.0"}
]
end
Download Your Schema
mix grephql.download_schema \
--endpoint https://api.example.com/graphql \
--output priv/schemas/schema.json \
--header "Authorization: Bearer YOUR_TOKEN"
Define a Client
defmodule MyApp.API do
use Grephql,
otp_app: :my_app,
source: "priv/schemas/schema.json",
endpoint: "https://api.example.com/graphql"
defgql :list_posts, ~GQL"""
query ListPosts($first: Int!) {
posts(first: $first) {
id
title
author {
name
}
}
}
"""
end
Configure Authentication
# config/runtime.exs
config :my_app, MyApp.API,
req_options: [auth: {:bearer, System.fetch_env!("API_TOKEN")}]
Use It
{:ok, result} = MyApp.API.list_posts(%{first: 10})
for post <- result.data.posts do
IO.puts("#{post.title} by #{post.author.name}")
end
Error Handling
case MyApp.API.list_posts(%{first: 10}) do
{:ok, result} ->
# result.data is a typed struct
# result.errors contains any GraphQL-level errors
process(result.data)
{:error, %Ecto.Changeset{} = changeset} ->
# Variable validation failed
Logger.error("Invalid variables: #{inspect(changeset.errors)}")
{:error, %Req.Response{} = response} ->
# HTTP error (4xx, 5xx)
Logger.error("HTTP #{response.status}")
{:error, exception} ->
# Network error
Logger.error(Exception.message(exception))
end
Testing with Req.Test
test "list_posts returns posts" do
Req.Test.stub(MyApp.API, fn conn ->
Req.Test.json(conn, %{
"data" => %{
"posts" => [
%{"id" => "1", "title" => "Hello", "author" => %{"name" => "Alice"}}
]
}
})
end)
{:ok, result} = MyApp.API.list_posts(%{first: 10},
req_options: [plug: {Req.Test, MyApp.API}]
)
assert [post] = result.data.posts
assert post.title == "Hello"
assert post.author.name == "Alice"
end
Formatter Integration
Add the Grephql formatter plugin to auto-format ~GQL sigils:
# .formatter.exs
[
plugins: [Grephql.Formatter]
]
Conclusion
Grephql brings compile-time safety to GraphQL in Elixir. By validating queries and generating typed schemas during compilation, it eliminates an entire category of runtime errors while providing a developer experience that feels natural to Elixir developers — Ecto schemas for responses, changesets for validation, and Req for HTTP.