mochi is a type-safe, Code First GraphQL library for Gleam. Define your GraphQL schemas using Gleam types and automatically generate TypeScript types and GraphQL SDL.
Inspired by:
- Absinthe - The GraphQL toolkit for Elixir
- dataloader - DataLoader pattern for batching and caching
- gqlkit - Write TypeScript, generate GraphQL
- Pothos - Code First GraphQL for TypeScript
- TypeGraphQL - Code First GraphQL with TypeScript decorators
- Nexus - Declarative, Code First GraphQL for TypeScript
Add to your gleam.toml:
mochi = { git = "https://github.com/qwexvf/mochi", ref = "main" }import mochi/query
import mochi/schema
import mochi/types
// 1. Define your Gleam types
pub type User {
User(id: String, name: String, email: String, age: Int)
}
// 2. Create GraphQL type with type-safe field extractors
fn user_type() -> schema.ObjectType {
types.object("User")
|> types.description("A user in the system")
|> types.id("id", fn(u: User) { u.id })
|> types.string("name", fn(u: User) { u.name })
|> types.string("email", fn(u: User) { u.email })
|> types.int("age", fn(u: User) { u.age })
|> types.build(decode_user)
}
// 3. Define queries
fn users_query() {
query.query(
name: "users",
returns: schema.list_type(schema.named_type("User")),
resolve: fn(_ctx) { Ok(get_users()) },
)
|> query.with_encoder(types.to_dynamic)
}
fn user_query() {
query.query_with_args(
name: "user",
args: [query.arg("id", schema.non_null(schema.id_type()))],
returns: schema.named_type("User"),
resolve: fn(args, _ctx) {
use id <- result.try(query.get_id(args, "id"))
get_user_by_id(id)
},
)
}
// 4. Build the schema
pub fn create_schema() -> schema.Schema {
query.new()
|> query.add_query(users_query())
|> query.add_query(user_query())
|> query.add_type(user_type())
|> query.build
}
// 5. Execute queries
pub fn main() {
let my_schema = create_schema()
let ctx = schema.execution_context(types.to_dynamic(dict.new()))
let result = executor.execute(my_schema, ctx, "{ users { id name } }", dict.new(), option.None)
}Mochi is built for performance on the BEAM VM.
Test system: AMD Ryzen 7 PRO 7840U (8 cores) · 64 GB RAM · 4 wrk threads · 100 connections · 10s runs · all servers in Docker
All servers run in Docker on the same bridge network. wrk runs on the host and hits each container via its mapped port.
| Server | Runtime | Req/sec | Latency |
|---|---|---|---|
| mochi | Gleam / BEAM | 22,910 | 4.37ms |
| bun + yoga | Bun | 13,412 | 7.44ms |
| yoga (node) | Node.js | 9,927 | 12.36ms |
| mercurius | Node.js + Fastify | 4,612 | 30.49ms |
| apollo | Node.js | 3,487 | 38.74ms |
| Server | Runtime | Req/sec | Latency |
|---|---|---|---|
| mochi | Gleam / BEAM | 11,647 | 8.56ms |
| bun + yoga | Bun | 7,303 | 13.66ms |
| yoga (node) | Node.js | 4,747 | 24.74ms |
| mercurius | Node.js + Fastify | 2,719 | 50.89ms |
| apollo | Node.js | 1,880 | 72.53ms |
| Server | Runtime | Req/sec | Latency |
|---|---|---|---|
| mochi | Gleam / BEAM | 19,046 | 5.25ms |
| bun + yoga | Bun | 11,037 | 9.04ms |
| mercurius | Node.js + Fastify | 9,497 | 15.15ms |
| yoga (node) | Node.js | 8,993 | 11.25ms |
| apollo | Node.js | 6,778 | 18.29ms |
| Server | Runtime | Req/sec | Latency |
|---|---|---|---|
| mochi | Gleam / BEAM | 10,601 | 9.41ms |
| bun + yoga | Bun | 6,954 | 14.35ms |
| mercurius | Node.js + Fastify | 4,692 | 25.02ms |
| yoga (node) | Node.js | 4,618 | 25.42ms |
| apollo | Node.js | 2,628 | 49.00ms |
BEAM scheduler vs. single-threaded JS event loop. Node.js serialises all requests through one event loop. The BEAM runs a scheduler per CPU core, each handling thousands of lightweight processes simultaneously. Under 100 concurrent connections mochi saturates all cores; Node.js cannot without clustering.
No shared-heap GC pauses. Every request on the BEAM lives in its own process heap. When a request finishes, that heap is reclaimed instantly — no stop-the-world pause. V8 has a single shared heap across all in-flight requests, so GC pauses add latency spikes for every concurrent request.
Flat execution path. Gleam pattern matching on union types compiles to native BEAM tagged-tuple dispatch. There are no promise chains, middleware stacks, or resolver-wrapping layers.
Note on the cache results. The Node.js servers gain 2–3× throughput from document caching (parse/validate is expensive relative to their execution cost). Mochi's throughput is slightly lower with cache enabled under 100 concurrent connections — the ETS table becomes a contention point at that concurrency level. The cache pays off at lower concurrency or with a wider variety of unique queries.
cd examples/mochi_wisp/benchmark
./run-host-bench.sh # runs both rounds automatically- Code First Schema Definition - Define GraphQL schemas using Gleam types with type-safe field extractors
- Parse Caching - ETS-backed document cache avoids re-parsing repeated queries
- Batch Execution - Execute multiple GraphQL requests in one call, with optional parallel dispatch
- TypeScript Codegen - Generate
.d.tstype definitions from your schema - SDL Generation - Generate
.graphqlschema files - SDL Type Extensions - Split schemas across multiple files using
extend type,extend union,extend enum, etc. - Operation Resolver Codegen - Generate mochi resolver boilerplate from
.gqlclient operation files - Query Validation - Validate GraphQL queries against your schema
- Custom Directives - Define and execute custom directives with handlers
- @deprecated Support - Mark fields and enum values as deprecated
- Interface Types - Define GraphQL interfaces with type resolution
- Union Types - Define union types with runtime type resolution
- Fragment Support - Full support for GraphQL fragments
- Subscriptions - Real-time updates with PubSub pattern
- Error Extensions - GraphQL-spec compliant errors with locations, path, and extensions
- JSON Serialization - Built-in JSON encoding for responses
- Null Propagation - Proper null bubbling per GraphQL specification
- DataLoader - N+1 query prevention with automatic batching and caching
- Guards - Composable precondition checks for authorization, feature flags, and access control
- Query Security - Depth limiting, complexity analysis, alias limits
- Persisted Queries - Automatic Persisted Queries (APQ) with SHA256 hashing
- GraphQL Playgrounds - Built-in GraphiQL, Playground, Apollo Sandbox, and simple explorer
- BEAM Powered - Built for performance on Erlang/OTP
- Zero Config - Simple, intuitive API with sensible defaults
Generate TypeScript type definitions from your schema:
import mochi_codegen
let ts_code = mochi_codegen.to_typescript(schema)
// Write to: types.generated.tsOutput:
// Generated by mochi
export type Maybe<T> = T | null | undefined;
export type Scalars = {
ID: string;
String: string;
Int: number;
Float: number;
Boolean: boolean;
};
export interface User {
id: Scalars["ID"];
name?: Maybe<Scalars["String"]>;
email?: Maybe<Scalars["String"]>;
age?: Maybe<Scalars["Int"]>;
}
export interface QueryUserArgs {
id: Scalars["ID"];
}
export interface Query {
user(args: QueryUserArgs): Maybe<User>;
users: Maybe<Maybe<User>[]>;
}Generate GraphQL SDL from your schema:
import mochi_codegen
let graphql_schema = mochi_codegen.to_sdl(schema)
// Write to: schema.graphqlOutput:
# Generated by mochi
"A user in the system"
type User {
id: ID!
name: String
email: String
age: Int
}
type Query {
"Get a user by ID"
user(id: ID!): User
"Get all users"
users: [User]!
}Split large schemas across multiple files using GraphQL type extensions. The CLI merges all files and resolves extensions before codegen.
# schema.graphql
type Mutation {
login(email: String!, password: String!): String!
}
# tournament.graphql
extend type Mutation {
finalizeTournament(tournamentId: ID!): Tournament!
}All six extension kinds are supported: extend type, extend interface, extend union, extend enum, extend input, extend scalar.
Rules:
- Extension fields are merged into the matching base type. Duplicate field names are ignored.
- An extension with no matching base type is treated as a standalone type definition (orphan extension).
- Multiple extensions for the same type are applied in file order.
Configure multiple schema files in mochi.config.json:
{
"schema": ["schema.graphql", "tournament.graphql"]
}Or use a glob pattern:
{
"schema": ["graphql/**/*.graphql"]
}Build GraphQL object types with type-safe field extractors. See module docs for full API.
import mochi/types
// Object type with field extractors
let user_type = types.object("User")
|> types.id("id", fn(u: User) { u.id })
|> types.string("name", fn(u: User) { u.name })
|> types.int("age", fn(u: User) { u.age })
|> types.build(decode_user)
// Enum type
let role_enum = types.enum_type("Role")
|> types.value("ADMIN")
|> types.value("USER")
|> types.build_enum
// Dynamic conversion helpers for DataLoader encoders
fn user_to_dynamic(u: User) -> Dynamic {
types.record([
types.field("id", u.id),
types.field("name", u.name),
#("age", types.option(u.age)), // Option -> null if None
])
}Define queries and mutations with type-safe resolvers. See module docs for full API.
import mochi/query
// Query with arguments
let user_query = query.query_with_args(
name: "user",
args: [query.arg("id", schema.non_null(schema.id_type()))],
returns: schema.named_type("User"),
resolve: fn(args, ctx) {
use id <- result.try(query.get_id(args, "id"))
get_user_by_id(id)
},
)
// Build schema
let my_schema = query.new()
|> query.add_query(user_query)
|> query.add_type(user_type)
|> query.buildConvenient helpers for extracting arguments in resolvers:
// Required arguments (return Result)
query.get_string(args, "name") // -> Result(String, String)
query.get_id(args, "id") // -> Result(String, String)
query.get_int(args, "age") // -> Result(Int, String)
query.get_float(args, "price") // -> Result(Float, String)
query.get_bool(args, "active") // -> Result(Bool, String)
// Optional arguments (return Option)
query.get_optional_string(args, "filter") // -> Option(String)
query.get_optional_int(args, "limit") // -> Option(Int)
// List arguments
query.get_string_list(args, "tags") // -> Result(List(String), String)
query.get_int_list(args, "ids") // -> Result(List(Int), String)Low-level schema building and type definitions. See module docs for full API.
import mochi/schema
// Field types
schema.string_type() // String
schema.int_type() // Int
schema.list_type(inner) // [Type]
schema.non_null(inner) // Type!
schema.named_type("User") // Custom type
// Interface and union types
let node = schema.interface("Node")
|> schema.interface_field(schema.field_def("id", schema.non_null(schema.id_type())))
let search = schema.union("SearchResult")
|> schema.union_member(user_type)
|> schema.union_member(post_type)Define custom directives for your schema. See module docs for full API.
import mochi/schema
let auth = schema.directive("auth", [schema.FieldLocation])
|> schema.directive_argument(schema.arg("role", schema.string_type()))
|> schema.directive_handler(fn(args, value) { Ok(value) })Guards are lightweight precondition checks that run before a resolver. If a
guard returns Ok(Nil), the resolver proceeds. If it returns Error(message),
the resolver is skipped entirely. See mochi/docs/guards.md for the
full guide.
// Define a reusable guard
fn require_auth(ctx: schema.ExecutionContext) -> Result(Nil, String) {
case get_current_user(ctx) {
Some(_) -> Ok(Nil)
None -> Error("Authentication required")
}
}
// High-level API: attach to queries, mutations, or fields
let my_posts = query.query_with_args(name: "myPosts", ...)
|> query.with_guard(require_auth)
let create_post = query.mutation(name: "createPost", ...)
|> query.with_guard(require_auth)
// Low-level API: attach directly to field definitions
schema.field_def("secret", schema.string_type())
|> schema.resolver(my_resolver)
|> schema.guard(require_auth_guard)
// Multiple guards (checked in list order)
|> schema.guards([require_auth_guard, require_admin_guard])Real-time updates with a PubSub pattern over WebSocket. Requires the mochi_websocket package.
import mochi_websocket
let pubsub = mochi_websocket.new_pubsub()
let state = mochi_websocket.new_connection(schema, pubsub, ctx)
mochi_websocket.publish(pubsub, mochi_websocket.topic("user:created"), user_data)GraphQL-spec compliant errors with extensions. See module docs for full API.
import mochi/error
let err = error.new("Something went wrong")
|> error.with_code("INTERNAL_ERROR")
|> error.with_extension("retryAfter", types.to_dynamic(60))Construct and serialize GraphQL responses. See module docs for full API.
import mochi/response
let resp = response.from_execution_result(exec_result)
let json_string = response.to_json(resp)Built-in JSON encoding. See module docs for full API.
import mochi/json
let json_string = json.encode(dynamic_value)The document cache is enabled automatically when you build a schema with query.build. It stores parsed ast.Document values in an ETS table (Erlang) or Map (JavaScript) keyed by the query string. Repeated requests for the same query skip the parser entirely.
The cache is bounded to 1000 entries by default. Once full, new unique queries fall back to parsing without caching.
import mochi/document_cache
// Automatic (via query.build — recommended)
let schema = query.new() |> ... |> query.build
// Cache is live; no extra config needed
// Manual size override via the low-level schema API
let cache = document_cache.new_with_max(500)
let schema = schema.schema()
|> schema.with_document_cache(cache)
|> ...Execute multiple GraphQL requests in a single call. Useful for HTTP batch endpoints.
import mochi/batch
let requests = [
batch.request("{ users { id name } }"),
batch.request_with_variables("{ user(id: $id) { name } }", vars),
batch.request_with_operation("query GetMe { me { id } }", "GetMe"),
]
let config = batch.default_config()
|> batch.with_max_batch_size(10)
|> batch.with_parallel_execution(True) // spawn one Erlang process per request
let result = batch.execute_batch(schema, requests, config, ctx)
// result.results -> List(ExecutionResult) in original order
// result.all_succeeded -> Bool
// result.failure_count -> IntProtect against malicious queries. See module docs for full API.
import mochi/security
case security.validate(document, security.default_config()) {
Ok(_) -> execute_query(document)
Error(err) -> error_response(err)
}APQ is built into the core — no extra package needed. Clients send a SHA256 hash instead of the full query string on repeat requests, reducing bandwidth on mobile or high-latency networks.
The protocol works in two passes:
- First request — client sends
{ "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "<hash>" } } }with noqueryfield. Server responds withPersistedQueryNotFound. - Second request — client resends with both
queryandextensions. Server verifies the hash, stores the query, and executes it. - All subsequent requests — client sends hash only. Server looks it up and executes directly.
Wire it into your HTTP handler by holding an apq.Store in your server state:
import gleam/dict
import gleam/option.{None, Some}
import mochi/apq
import mochi/executor
// Server state — hold this across requests (e.g. in an Agent or ETS)
let store = apq.new()
// In your request handler:
fn handle_graphql(body: String, store: apq.Store) -> #(apq.Store, String) {
let #(query_opt, extensions) = parse_request(body)
case apq.parse_extension(extensions) {
None -> {
// Normal request — no APQ
let result = executor.execute_query_with_context(schema, query_opt, ...)
#(store, respond(result))
}
Some(ext) -> {
case apq.process(store, query_opt, ext.sha256_hash) {
Ok(#(store, query)) -> {
let result = executor.execute_query_with_context(schema, query, ...)
#(store, respond(result))
}
Error(apq.NotFound) ->
#(store, persisted_query_not_found_response())
Error(apq.HashMismatch(..)) ->
#(store, bad_request_response("hash mismatch"))
}
}
}
}The apq.Store is an immutable dict — you get a new one back from apq.process whenever a query is registered. Store it in an Erlang Agent or ETS table to share it across the process pool.
Built-in interactive GraphQL explorers. Requires the mochi_codegen package.
import mochi_codegen
mochi_codegen.graphiql("/graphql") // GraphiQL IDE
mochi_codegen.apollo_sandbox("/graphql") // Apollo SandboxReal-time subscriptions over WebSocket using the graphql-ws protocol. Requires the mochi_websocket package.
import mochi_websocket
let state = mochi_websocket.new_connection(schema, pubsub, ctx)
let result = mochi_websocket.handle_message(state, client_msg)Prevent N+1 queries with automatic batching. See module docs for full API.
import mochi/dataloader
import mochi/schema
// Create loader from find function (one-liner)
let pokemon_loader = dataloader.int_loader_result(
data.find_pokemon, pokemon_to_dynamic, "Pokemon not found",
)
// Register loaders and load data
let ctx = schema.execution_context(types.to_dynamic(dict.new()))
|> schema.with_loaders([#("pokemon", pokemon_loader)])
let #(ctx, result) = schema.load_by_id(ctx, "pokemon", 25)Generate TypeScript types, GraphQL SDL, Gleam resolver stubs, and serve playground UIs. Requires the mochi_codegen package.
import mochi_codegen
let ts_code = mochi_codegen.to_typescript(schema)
let graphql_code = mochi_codegen.to_sdl(schema)CLI — generate everything from a config file:
gleam run -m mochi_codegen/cli -- init # create mochi.config.yaml
gleam run -m mochi_codegen/cli -- generate # generate from configOperation resolver generation — point the CLI at your .gql client operation files and it emits complete mochi field-builder boilerplate (decode blocks, resolve stubs, encoder stubs, and a register() function). Only fill in the resolve: body.
# mochi.config.yaml
schema: "graphql/schema.graphql"
operations_input: "src/graphql/**/*.gql"
output:
gleam_types: "src/api/domain/"
resolvers: "src/api/schema/"
operations: "src/api/schema/"
typescript: "apps/web/src/generated/types.ts"See the mochi-examples repository for complete working examples:
code_first_example.gleam- Basic User/Post schema with queries and mutationscore_library_examples/- Pure GraphQL functionality demonstrationsmochi_wisp/- Full web application with Wisp framework
import mochi/query
import mochi/schema
import mochi/types
pub type User {
User(id: String, name: String, email: String)
}
pub type Post {
Post(id: String, title: String, author_id: String)
}
fn user_type() -> schema.ObjectType {
types.object("User")
|> types.id("id", fn(u: User) { u.id })
|> types.string("name", fn(u: User) { u.name })
|> types.string("email", fn(u: User) { u.email })
|> types.build(fn(_) { Ok(User("", "", "")) })
}
fn post_type() -> schema.ObjectType {
types.object("Post")
|> types.id("id", fn(p: Post) { p.id })
|> types.string("title", fn(p: Post) { p.title })
|> types.string("authorId", fn(p: Post) { p.author_id })
|> types.build(fn(_) { Ok(Post("", "", "")) })
}
pub fn create_schema() -> schema.Schema {
query.new()
|> query.add_query(
query.query(
name: "users",
returns: schema.list_type(schema.named_type("User")),
resolve: fn(_) { Ok([]) },
)
|> query.with_encoder(types.to_dynamic),
)
|> query.add_type(user_type())
|> query.add_type(post_type())
|> query.build
}pub type CreateUserInput {
CreateUserInput(name: String, email: String)
}
fn create_user_mutation() {
query.mutation_with_args(
name: "createUser",
args: [query.arg("input", schema.non_null(schema.named_type("CreateUserInput")))],
returns: schema.named_type("User"),
resolve: fn(args, _ctx) {
use input <- result.try(query.decode_input(args, "input", input_decoder))
let user = User(id: generate_id(), name: input.name, email: input.email)
db.insert_user(user)
Ok(user)
},
)
|> query.with_description("Create a new user")
}
let schema = query.new()
|> query.add_mutation(create_user_mutation())
|> query.buildInstall only the packages you need — each is a separate repository:
mochi = { git = "https://github.com/qwexvf/mochi", ref = "main" } # Core (required)
mochi_relay = { git = "https://github.com/qwexvf/mochi_relay", ref = "main" } # Relay-style cursor pagination
mochi_websocket = { git = "https://github.com/qwexvf/mochi_websocket", ref = "main" } # WebSocket subscriptions (graphql-ws)
mochi_upload = { git = "https://github.com/qwexvf/mochi_upload", ref = "main" } # Multipart file uploads
mochi_codegen = { git = "https://github.com/qwexvf/mochi_codegen", ref = "main" } # SDL + TypeScript codegen + GraphiQL
# mochi/apq is built into the core — no separate package neededmochi/ # Core GraphQL engine
├── query.gleam # Query/Mutation/Subscription builders
├── types.gleam # Type builders (object, enum, fields)
├── schema.gleam # Core schema types and low-level API
├── executor.gleam # Query execution with null propagation
├── validation.gleam # Query validation
├── document_cache.gleam # ETS-backed parse cache (Erlang + JS)
├── batch.gleam # Batch query execution with parallel dispatch
├── dataloader.gleam # N+1 query prevention
├── error.gleam # GraphQL-spec compliant errors
├── response.gleam # Response serialization
└── security.gleam # Depth/complexity/alias limits
mochi_relay/ # Relay cursor pagination
mochi_websocket/ # graphql-ws WebSocket transport + PubSub
mochi_upload/ # GraphQL multipart file uploads
mochi_codegen/ # SDL + TypeScript + Gleam codegen + GraphiQL + CLI
mochi_apq/ # Automatic Persisted Queries (standalone)
gleam testgleam build # Build
gleam check # Check for warnings
gleam format src test # FormatApache 2.0