typedGql (formerly Grephql)

Build StatusHex.pmHexDocs

Compile-time GraphQL client for Elixir. Parses and validates queries during compilation, generates typed Ecto embedded schemas for responses and variables, and executes queries at runtime via Req.

Features

Installation

Add typed_gql to your dependencies in mix.exs:

def deps do
[
{:typed_gql, "~> 0.12.0"}
]
end

Quick Start

1. Download your schema

mix typed_gql.download_schema \
--endpoint https://api.example.com/graphql \
--output priv/schemas/schema.json \
--header "Authorization: Bearer token123"

2. Define a client module

defmodule MyApp.GitHub do
use TypedGql,
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
}
}
"""
defgql :get_viewer, ~GQL"""
query {
viewer {
login
email
}
}
"""
end

3. Call the generated functions

# With variables — validates input before sending
case MyApp.GitHub.get_user(%{login: "octocat"}) do
{:ok, result} ->
result.data.user.name #=> "The Octocat"
{:error, %Ecto.Changeset{} = changeset} ->
# Variable validation failed
changeset.errors
{:error, %Req.Response{} = response} ->
# HTTP error
response.status
end
# Without variables
{:ok, result} = MyApp.GitHub.get_viewer()
result.data.viewer.login

Macros

defgql / defgqlp

Defines a public (or private) GraphQL query function. At compile time: parses, validates, generates typed modules, and defines a callable function.

# Public — generates def get_user/2
defgql :get_user, ~GQL"""
query GetUser($id: ID!) {
user(id: $id) { name }
}
"""
# Private — generates defp get_user/2
defgqlp :get_user, ~GQL"""
query GetUser($id: ID!) {
user(id: $id) { name }
}
"""

defgql functions automatically include @doc with operation info, variable table, and all generated module names.

deffragment

Defines a reusable named fragment. The fragment name comes from the GraphQL definition itself, so deffragment only takes the fragment string. Fragments are validated at compile time and automatically appended to queries that reference them via ...FragmentName.

deffragment ~GQL"""
fragment UserFields on User {
name
email
createdAt
}
"""
defgql :get_user, ~GQL"""
query GetUser($id: ID!) {
user(id: $id) {
...UserFields
}
}
"""

The fragment generates a typed module at Client.Fragments.UserFields. defgql only sees fragments defined before it in the module. If the same fragment name is defined multiple times before a query, the latest definition overrides earlier ones for that query.

Configuration

Configuration is resolved in order (later wins): compile-time defaults -> runtime config -> per-call opts.

Compile-time (in use)

use TypedGql,
otp_app: :my_app,
source: "priv/schemas/github.json",
endpoint: "https://api.github.com/graphql",
req_options: [receive_timeout: 30_000],
scalars: %{"DateTime" => TypedGql.Types.DateTime}

Runtime (application config)

# config/runtime.exs
config :my_app, MyApp.GitHub,
endpoint: "https://api.github.com/graphql",
req_options: [auth: {:bearer, System.fetch_env!("GITHUB_TOKEN")}]

Per-call

MyApp.GitHub.get_user(%{login: "octocat"},
endpoint: "https://other.api.com/graphql",
req_options: [receive_timeout: 60_000]
)

The ~GQL Sigil and Formatter

The ~GQL sigil marks GraphQL strings for automatic formatting by mix format. Plain strings still work with defgql~GQL is optional.

Add the formatter plugin to your .formatter.exs:

[
plugins: [TypedGql.Formatter],
# ...
]

Or via dependency import:

[
import_deps: [:typed_gql],
# ...
]

Before / After

# Before
defgql :get_user, ~GQL"query GetUser($id: ID!) { user(id: $id) { name email posts { title } } }"
# After mix format
defgql :get_user, ~GQL"""
query GetUser($id: ID!) {
user(id: $id) {
name
email
posts {
title
}
}
}
"""

Custom Scalars

Map GraphQL custom scalars to Ecto types via the :scalars option:

use TypedGql,
otp_app: :my_app,
source: "schema.json",
scalars: %{
"DateTime" => TypedGql.Types.DateTime,
"JSON" => :map
}

TypedGql.Types.DateTime is included for ISO 8601 DateTime strings. For other custom scalars, provide any module implementing the Ecto.Type behaviour.

Enums

GraphQL enums are automatically converted to snake_cased atoms:

# GraphQL: enum Role { ADMIN, READ_ONLY }
# Elixir: :admin, :read_only

Loading is case-insensitive — "ADMIN", "admin", and "Admin" all resolve to :admin.

Unions and Interfaces

Union and interface types are resolved at decode time using the __typename field:

defgql :search, ~GQL"""
query Search($q: String!) {
search(query: $q) {
... on User {
name
}
... on Repository {
fullName
}
}
}
"""
{:ok, result} = MyApp.GitHub.search(%{q: "elixir"})
Enum.each(result.data.search, fn
%MyApp.GitHub.Search.Result.Search.User{} = user -> IO.puts(user.name)
%MyApp.GitHub.Search.Result.Search.Repository{} = repo -> IO.puts(repo.full_name)
end)
# __typename is also available as a snake_cased atom
hd(result.data.search).__typename #=> :user

Field Aliases

GraphQL aliases affect both the struct field name and the module path:

defgql :get_account, ~GQL"""
query {
account: user(login: "alice") {
displayName: name
}
}
"""
{:ok, result} = MyApp.GitHub.get_account()
result.data.account.display_name # alias becomes the field name

Generated Modules

Each defgql generates typed Ecto embedded schema modules at compile time. Given defgql :get_user inside MyApp.GitHub:

TypePatternExample
ResultClient.FnName.Result.Field...MyApp.GitHub.GetUser.Result.User
Nested field...Result.Field.NestedFieldMyApp.GitHub.GetUser.Result.User.Posts
VariablesClient.FnName.VariablesMyApp.GitHub.GetUser.Variables
Input typesClient.Inputs.TypeNameMyApp.GitHub.Inputs.CreateUserInput
FragmentClient.Fragments.NameMyApp.GitHub.Fragments.UserFields
Union variant...Result.Field.TypeNameMyApp.GitHub.Search.Result.Search.User

Naming rules

Example

defmodule MyApp.GitHub do
use TypedGql, otp_app: :my_app, source: "schema.json"
deffragment ~GQL"""
fragment PostFields on Post {
title
body
}
"""
defgql :get_user, ~GQL"""
query GetUser($id: ID!) {
author: user(id: $id) {
name
posts {
...PostFields
}
}
}
"""
end
# Generated modules:
# MyApp.GitHub.Fragments.PostFields — %{title: String.t(), body: String.t()}
# MyApp.GitHub.GetUser.Result — %{author: Author.t()}
# MyApp.GitHub.GetUser.Result.Author — %{name: String.t(), posts: [Posts.t()]}
# MyApp.GitHub.GetUser.Result.Author.Posts — %{title: String.t(), body: String.t()}
# MyApp.GitHub.GetUser.Variables — %{id: String.t()}

Access Behaviour

All generated schemas implement the Access behaviour, so you can use bracket syntax and get_in/2 alongside dot notation:

{:ok, result} = MyApp.GitHub.get_user(%{login: "octocat"})
# Dot notation
result.data.user.name
# Bracket syntax
result.data.user[:name]
# Dynamic / nested access
get_in(result.data, [:user, :name])

This is powered by TypedStructor.Plugins.Access, which is registered in every generated typed_embedded_schema block.

Customizing Requests (prepare_req/1)

Each client module has an overridable prepare_req/1 callback that receives the %Req.Request{} before it is sent — attach Req steps, add headers, or capture response metadata into Result.assigns:

def prepare_req(req) do
Req.Request.append_response_steps(req,
request_id: fn {req, resp} ->
{req, TypedGql.Result.put_resp_assign(resp, :request_id, Req.Response.get_header(resp, "x-request-id"))}
end
)
end

See the Customizing Requests with prepare_req guide for more details.

Testing

Use Req.Test to stub HTTP responses without any network calls:

# config/test.exs
config :my_app, MyApp.GitHub,
req_options: [plug: {Req.Test, MyApp.GitHub}]
test "get_user returns user data" do
Req.Test.stub(MyApp.GitHub, fn conn ->
Req.Test.json(conn, %{
"data" => %{"user" => %{"name" => "Alice", "bio" => "Elixirist"}}
})
end)
assert {:ok, result} = MyApp.GitHub.get_user(%{login: "alice"})
assert result.data.user.name == "Alice"
end

Telling requests apart

When several defgql functions share one stub, attach TypedGql.OperationInfo so the stub can branch on which function made the request:

defmodule MyApp.GitHub do
use TypedGql, otp_app: :my_app, source: "priv/schemas/github.json"
# opt in for tests only
if Mix.env() == :test do
def prepare_req(req), do: TypedGql.OperationInfo.attach(req)
end
# ...
end
test "page loads user and posts" do
Req.Test.expect(MyApp.GitHub, 2, fn conn ->
case TypedGql.OperationInfo.get(conn).function do
"get_user" -> Req.Test.json(conn, %{"data" => %{"user" => %{"name" => "Alice"}}})
"list_posts" -> Req.Test.json(conn, %{"data" => %{"posts" => []}})
end
end)
# ... code that calls both MyApp.GitHub.get_user/1 and list_posts/1
end

Mix Tasks

mix typed_gql.download_schema

Downloads a GraphQL schema via introspection and saves it as JSON.

mix typed_gql.download_schema --endpoint URL --output PATH [--header "Key: Value"]
OptionRequiredDescription
--endpoint / -eyesGraphQL endpoint URL
--output / -oyesFile path to save the schema JSON
--header / -hnoHTTP header in "Key: Value" format (repeatable)

use TypedGql Options

OptionRequiredDescription
:otp_appyesOTP application for runtime config lookup
:sourceyesPath to introspection JSON (relative to caller file) or inline JSON string
:endpointnoDefault GraphQL endpoint URL
:req_optionsnoDefault Req options (keyword list)
:scalarsnoMap of GraphQL scalar name to Ecto type (default: %{})

JSON Library

Defaults to Elixir 1.18+ built-in JSON, falls back to Jason. To override:

config :typed_gql, :json_library, Jason

Any module implementing encode!/1 and decode/1 works.

Requirements

License

See LICENSE for details.