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.
- Compile-time validation — Syntax errors, schema mismatches, and deprecated usage caught at
mix compile, with errors pointing to file:line:column in your Elixir source - Minimal code generation — Only generates modules for types actually referenced in your queries, not the entire schema
- Zero runtime parsing — All GraphQL parsing happens at compile time
- Typed responses — JSON responses are cast into typed Ecto embedded schemas, accessible via dot notation,
[:field], andget_in - Typed variables — Input validation via Ecto changesets with generated
params()type - Union / Interface support — Automatic type dispatch, no extra fields needed
- Req integration — Full access to Req's middleware/plugin system, test with
Req.Testdirectly - Response metadata — Capture arbitrary response metadata (e.g. GraphQL
extensions) viaprepare_req/1callback andResult.assigns - Configurable JSON library — Defaults to Elixir 1.18+ built-in
JSON, falls back toJason, or set your own via:json_library - Auto-generated docs —
defgqlfunctions include@docwith variables, types, and generated module listing
Add typed_gql to your dependencies in mix.exs:
def deps do
[
{:typed_gql, "~> 0.13.0"}
]
endmix typed_gql.download_schema \
--endpoint https://api.example.com/graphql \
--output priv/schemas/schema.json \
--header "Authorization: Bearer token123"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# 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
{:error, %TypedGql.DecodeError{} = error} ->
# The server answered 2xx with data the result schema cannot load
error.message
end
# Without variables
{:ok, result} = MyApp.GitHub.get_viewer()
result.data.viewer.loginA tour of the three macros follows; TypedGql.Macros documents each one in full.
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.
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 — an embedded schema for an object condition, or the TypedGql.Types.Union parameterized type when the condition resolves to per-member selections.
defgql only sees fragments defined before it in the module, and a fragment body may only spread fragments defined above it — spreading one defined later is a compile error. If the same name is defined several times before a query, the latest definition wins for that query. See TypedGql.Macros.deffragment/1 for why the macro model requires this while GraphQL itself does not.
Configuration is resolved in order (later wins): compile-time defaults -> runtime config -> per-call opts.
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}# config/runtime.exs
config :my_app, MyApp.GitHub,
endpoint: "https://api.github.com/graphql",
req_options: [auth: {:bearer, System.fetch_env!("GITHUB_TOKEN")}]MyApp.GitHub.get_user(%{login: "octocat"},
endpoint: "https://other.api.com/graphql",
req_options: [receive_timeout: 60_000]
)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
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
}
}
}
"""Map GraphQL custom scalars to Ecto types via the :scalars option:
use TypedGql,
otp_app: :my_app,
source: "schema.json",
scalars: %{
"GitObjectID" => :string,
"Money" => MyApp.Types.Money
}A scalar maps to a plain Ecto type or to any module implementing the Ecto.Type
behaviour. Widely used scalars — DateTime, Date, JSON, URI, URL,
BigInt, HTML, Base64String — are already mapped (DateTime to
TypedGql.Types.DateTime), so only schema-specific ones need an entry, and a
mapping you give for a name that already has one replaces it.
See the custom scalars guide for the full
list of built-ins and for which Ecto.Type callbacks run on each path.
GraphQL enums are automatically converted to snake_cased atoms:
# GraphQL: enum Role { ADMIN, READ_ONLY }
# Elixir: :admin, :read_onlyLoading is case-insensitive — "ADMIN", "admin", and "Admin" all resolve to :admin.
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 #=> :userGraphQL 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 namedefgql works the same for mutations. GraphQL input object types become
shared modules under Client.Inputs.*:
defgql :create_issue, ~GQL"""
mutation CreateIssue($input: CreateIssueInput!) {
createIssue(input: $input) {
issue {
number
title
state
}
}
}
"""# Plain maps are validated against the generated input schema.
# Params use the snake_cased field names, not the GraphQL spelling.
{:ok, result} =
MyApp.GitHub.create_issue(%{input: %{repository_id: "R_1", title: "Bug"}})
result.data.create_issue.issue.state #=> :open
# Missing required fields fail before any request is sent
{:error, changeset} = MyApp.GitHub.create_issue(%{input: %{}})
Ecto.Changeset.traverse_errors(changeset.changes.input, fn {msg, _opts} -> msg end)
#=> %{repository_id: ["can't be blank"], title: ["can't be blank"]}The generated input module can also be used directly — build/1 returns
{:ok, struct} or {:error, changeset}, and nested input objects are
embedded:
{:ok, input} =
MyApp.GitHub.Inputs.CreateIssueInput.build(%{
repository_id: "R_1",
title: "Bug",
body: "Steps to reproduce...",
label_ids: ["L_1", "L_2"]
})
{:ok, result} = MyApp.GitHub.create_issue(%{input: input})Input modules are shared across the whole client, so two operations using
CreateIssueInput refer to the same module. See
examples/github_client.exs for a runnable version.
Each operation with variables gets a Client.FnName.Variables module. Calls
run the caller's map through it before any request is sent, and it can be used
standalone to validate or inspect a payload:
# Same validation the client performs on every call
{:ok, vars} = MyApp.GitHub.GetUser.Variables.build(%{id: "U_1"})
{:error, changeset} = MyApp.GitHub.GetUser.Variables.build(%{})
Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
#=> %{id: ["can't be blank"]}
# Params use the snake_cased names; the GraphQL name is kept for serialization
MyApp.GitHub.GetUser.Variables.__schema__(:fields) #=> [:id]Variables are per-operation, so two operations declaring $id: ID! get
separate modules — unlike input types, which are shared.
Each defgql generates typed Ecto embedded schema modules at compile time. Given defgql :get_user inside MyApp.GitHub:
| Type | Pattern | Example |
|---|---|---|
| Fragment | Client.Fragments.Name |
MyApp.GitHub.Fragments.UserFields |
| Result | Client.FnName.Result.Field... |
MyApp.GitHub.GetUser.Result.User |
| Nested field | ...Result.Field.NestedField |
MyApp.GitHub.GetUser.Result.User.Posts |
| Union variant | ...Result.Field.TypeName |
MyApp.GitHub.Search.Result.Search.User |
| Input types | Client.Inputs.TypeName |
MyApp.GitHub.Inputs.CreateIssueInput |
| Variables | Client.FnName.Variables |
MyApp.GitHub.GetUser.Variables |
- Function name is CamelCased:
:get_user->GetUser - Struct field names are snake_cased:
userName->:user_name - Field aliases override both field name and module path:
author: user { ... }-> field:author, module...Result.Author - Fragment modules live under
Client.Fragments.* - Input types are shared across operations under
Client.Inputs.* - Variables are per-operation under
Client.FnName.Variables
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()}The posts: [Posts.t()] above assumes the schema declares it [Post!]!. A list
typespec mirrors the schema's nullability at every level — [Post] generates
[Posts.t() | nil] | nil — and a nullable list's struct default is nil, not [].
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.
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
)
endSee the Customizing Requests with prepare_req guide for more details.
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"
endWhen 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
# ...
endtest "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
endDownloads a GraphQL schema via introspection and saves it as JSON.
mix typed_gql.download_schema --endpoint URL --output PATH [--header "Key: Value"]| Option | Required | Description |
|---|---|---|
--endpoint / -e |
yes | GraphQL endpoint URL |
--output / -o |
yes | File path to save the schema JSON |
--header / -h |
no | HTTP header in "Key: Value" format (repeatable) |
| Option | Required | Description |
|---|---|---|
:otp_app |
yes | OTP application for runtime config lookup |
:source |
yes | Path to introspection JSON (relative to caller file) or inline JSON string |
:endpoint |
no | Default GraphQL endpoint URL. The URL may also come from req_options (:url/:base_url) or prepare_req/1, both of which take precedence over it |
:req_options |
no | Default Req options (keyword list) |
:scalars |
no | Map of GraphQL scalar name to Ecto type (default: %{}) |
:generation_plugins |
no | Extra TypedGql.Generation.Plugin modules, run after the built-in ones |
Defaults to Elixir 1.18+ built-in JSON, falls back to Jason. To override:
config :typed_gql, :json_library, JasonAny module implementing encode!/1 and decode/1 works.
- Elixir ~> 1.15
- Erlang/OTP 26+ (the oldest pair covered by CI)
See LICENSE for details.