Hono GraphQL¶
Node.js GraphQL Validation Schema-first
A working GraphQL Yoga server mounted on Hono, backed by SQLite via libsql + Drizzle ORM. The wizard generates an end-to-end Users CRUD example — SDL, resolvers, table, and a built-in GraphiQL IDE — so the moment you run pnpm dev you can open http://localhost:4000/graphql and execute a mutation.
The template is schema-first by design: the contract lives in src/schema.graphql (SDL) and is mirrored by a typeDefs template string in src/GraphqlSchema.res. Resolvers are plain ReScript functions in src/Resolvers.res, wired into rootValue by name. If you outgrow this style, the README contrasts it with code-first builders (Pothos, Nexus, gqtx) so the migration decision stays informed.
What You Get¶
my-project/
├── rescript.json
├── package.json
├── drizzle.config.ts # drizzle-kit config (reads Schema.res.mjs)
├── vitest.config.mjs # registers vitest.setup.mjs
├── vitest.setup.mjs # pins DATABASE_URL=:memory: before module load
├── src/
│ ├── ServerMain.res # entry — calls Server.start()
│ ├── Server.res # Hono app, mounts yoga at /graphql
│ ├── GraphqlSchema.res # typeDefs + rootValue consumed by yoga
│ ├── Resolvers.res # module Users { listUsers / userById / createUser / deleteUser }
│ ├── Schema.res # Drizzle SQLite tables (users)
│ ├── Db.res # libsql client + Drizzle helpers
│ ├── Yoga.res # graphql-yoga bindings
│ ├── Hono.res # Hono bindings
│ ├── HonoNodeServer.res # @hono/node-server bindings
│ ├── Validation.res # zod or sury — selected in the wizard
│ ├── schema.graphql # human-authored SDL (mirror of typeDefs)
│ └── __tests__/Server.test.mjs # vitest hits app.request("/health")
├── README.md # Try It / Schema / Database / Project Layout
├── LICENSE # MIT, holder = project name
├── .env.example # documents DATABASE_URL
├── .nvmrc # Node 24
├── .gitignore # node_modules, data/, docs/schema.md, drizzle/, .env
├── .editorconfig # 2-space indent, LF line endings
└── .github/
├── dependabot.yml # weekly npm updates
└── workflows/ci.yml # install + rescript build + vitest
Wizard Options¶
Option |
Effect |
|---|---|
Project name |
Becomes the npm |
Package manager |
npm / pnpm / yarn / bun. Affects |
Validation library |
|
Key Dependencies¶
Package |
Purpose |
Version |
|---|---|---|
|
ReScript compiler |
|
|
Standard library |
|
|
Runtime stubs the compiled |
|
|
HTTP router that hosts yoga |
|
|
Node adapter for Hono |
|
|
Reference implementation (peer of yoga) |
|
|
Schema executor + GraphiQL UI |
|
|
SQLite driver (also speaks Turso |
|
|
Type-safe SQL builder |
|
|
Mutation input validation (chosen in the wizard) |
|
|
Migration generator/runner |
|
|
Renders SDL to Markdown for |
|
|
Smoke test runner |
|
|
Coverage provider for |
|
Key Files¶
src/schema.graphql¶
The human-authored source of truth. Read by @graphql-markdown/cli (and any external GraphQL tool — Apollo Studio, Hasura, codegen) to render docs/schema.md:
type User {
id: Int!
name: String!
email: String!
}
type Query {
users: [User!]!
user(id: Int!): User
}
type Mutation {
createUser(name: String!, email: String!): User!
deleteUser(id: Int!): Boolean!
}
src/GraphqlSchema.res¶
Builds the runtime schema yoga executes. The typeDefs template string must mirror src/schema.graphql — the SDL file is what tooling reads, but yoga consumes the inline string so the bundle stays self-contained:
let typeDefs = `
type User { id: Int!, name: String!, email: String! }
type Query { users: [User!]!, user(id: Int!): User }
type Mutation {
createUser(name: String!, email: String!): User!
deleteUser(id: Int!): Boolean!
}
`
let schema = Yoga.buildSchema(typeDefs)
let rootValue: {..} = Obj.magic({
"users": Resolvers.Users.listUsers,
"user": Resolvers.Users.userById,
"createUser": Resolvers.Users.createUser,
"deleteUser": Resolvers.Users.deleteUser,
})
Obj.magic is intentional — it erases the polymorphic (parent, args, ctx, info) => 'a resolver shape so the record can be generalised. The concrete signatures are checked at each resolver definition site in Resolvers.res.
src/Resolvers.res¶
Each GraphQL type gets a nested module so GraphqlSchema.res can reference fields as Resolvers.<Type>.<field>. The shipped Users module shows the full CRUD surface:
module Users = {
let listUsers = async (_parent, _args, _ctx, _info) => ...
let userById = async (_parent, args, _ctx, _info) => ...
let createUser = async (_parent, args, _ctx, _info) => {
switch Validation.parseCreateUserInput(args->Obj.magic) {
| Error(msg) => failwith(msg) // surfaced as a GraphQL error
| Ok(payload) => ... // Drizzle insert + returning
}
}
let deleteUser = async (_parent, args, _ctx, _info) => ...
}
createUser runs every input through Validation.parseCreateUserInput and failwiths on error — graphql-yoga catches the throw and translates it into an errors[] entry on the response. No extra plumbing.
src/Server.res¶
Wires Hono, mounts yoga at /graphql (GET + POST so GraphiQL works), and exposes start() separately so vitest can import("../Server.res.mjs") without binding a port:
let yoga = Yoga.createYoga({
"schema": GraphqlSchema.schema,
"rootValue": GraphqlSchema.rootValue,
})
let app = Hono.createApp()
app->Hono.onError((err, ctx) => {
Console.error(err)
ctx->Hono.status(500)->Hono.json({"error": "Internal Server Error"})
})
app->Hono.get("/health", ctx => ctx->Hono.json({"status": "ok"}))
app->Hono.get("/graphql", handleYoga)
app->Hono.post("/graphql", handleYoga)
let start = () => {
HonoNodeServer.serve({fetch: app->HonoNodeServer.honoFetch, port: 4000})
Console.log("Server on http://localhost:4000 — GraphiQL at /graphql")
}
src/ServerMain.res¶
A two-line entry point that the npm start / dev scripts run:
Server.start()
Kept separate from Server.res so app stays importable from tests without side effects.
src/Schema.res¶
Drizzle SQLite table definitions consumed by db:generate:
let users = sqliteTable("users", {
"id": intCol("id", {"primaryKey": true, "autoIncrement": true}),
"name": textCol("name", {"notNull": true}),
"email": textCol("email", {"notNull": true}),
})
drizzle.config.ts¶
Points drizzle-kit at the compiled schema (Schema.res.mjs) and reads DATABASE_URL from the environment, defaulting to file:./data/app.db:
export default defineConfig({
schema: "./src/Schema.res.mjs",
out: "./drizzle",
dialect: "sqlite",
dbCredentials: {
url: process.env.DATABASE_URL ?? "file:./data/app.db",
},
});
vitest.config.mjs + vitest.setup.mjs¶
The setup file pins DATABASE_URL to libsql’s in-memory store before any module loads:
process.env.DATABASE_URL = ":memory:";
This matters because importing Server.res.mjs transitively loads Db → Schema → Resolvers, and without the override the import chain would try to open ./data/app.db during test collection.
src/Validation.res¶
Both variants expose parseCreateUserInput: JSON.t => result<createUserInput, string>. The createUser resolver failwiths on Error, which graphql-yoga converts into a GraphQL error.
npm Scripts¶
Script |
Description |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Adding a New Type and Resolver¶
The README walks through this in detail; the workflow boils down to six steps. Use this template’s existing Users type as a worked example you can copy.
Edit
src/schema.graphqlwith the new type and root fields:type Post { id: Int!, title: String!, authorId: Int! } extend type Query { posts: [Post!]! }
Mirror it in
src/GraphqlSchema.res#typeDefsso the runtime schema and the SDL stay in lock-step.Add a Drizzle table in
src/Schema.resand runpnpm db:generate+pnpm db:migrate.Add a resolver module in
src/Resolvers.res:module Posts = { let listPosts = async (_p, _a, _c, _i) => await Db.db ->Db.select({"id": Schema.posts["id"], "title": Schema.posts["title"], "authorId": Schema.posts["authorId"]}) ->Db.from(Schema.posts) ->Db.allAsync }
Wire it into
rootValueinGraphqlSchema.res:let rootValue: {..} = Obj.magic({ // existing user resolvers... "posts": Resolvers.Posts.listPosts, })
Regenerate docs:
pnpm docs:graphqlwritesdocs/schema.md.
Mutations follow the same pattern, with one extra step: add a parsePostInput to Validation.res and failwith on Error so graphql-yoga can translate the throw into a GraphQL error.
Testing Resolvers in Isolation¶
src/__tests__/Server.test.mjs covers the HTTP boundary against the /health route. For resolver-level tests, hit the schema directly with graphql/execution:
import { graphql } from "graphql";
import { schema, rootValue } from "../GraphqlSchema.res.mjs";
it("listUsers returns rows from Db.allAsync", async () => {
const result = await graphql({
schema,
rootValue,
source: `query { users { id name } }`,
});
expect(result.errors).toBeUndefined();
expect(Array.isArray(result.data.users)).toBe(true);
});
For pure unit tests, mock Db.allAsync with vi.mock("../Db.res.mjs", ...) so you exercise resolver logic without spinning up SQLite. Combined with the vitest.setup.mjs in-memory DATABASE_URL pin, you can run the entire suite without filesystem I/O.
Day-Two Recipes¶
Add a GraphQL Resolver — add a new type + resolver, including the SDL/typeDefs sync workflow
Set Up Drizzle — Drizzle schema, migrations, and Turso swap
Add a Hono Endpoint — add a REST route alongside
/graphql(e.g. webhook intake, health probes)Add OpenAPI Docs — bolt OpenAPI on top if you grow a parallel REST surface
For ReScript-side editor workflows once the project is open, see the Feature Overview.
Notes¶
SDL and typeDefs must agree. The SDL file (
src/schema.graphql) is whatgraphql-markdownand external tools read;typeDefsinGraphqlSchema.resis what yoga executes. They are two copies on purpose — the bundle stays self-contained — but they are easy to drift. A pre-commit hook runningnode -e "require('graphql').buildSchema(require('fs').readFileSync('src/schema.graphql','utf-8'))"catches SDL parse errors before CI does.Schema-first vs code-first. This template is opinionated toward schema-first because it is friendlier to polyglot teams, public schemas, and external codegen. If you have many contributors and heavy schema churn, the README’s Schema section walks through swapping
GraphqlSchema.resfor a code-first builder (Pothos, Nexus). Drizzle and Hono stay; only the schema/resolver wiring changes.Resolver-level testing.
src/__tests__/Server.test.mjsonly covers the HTTP boundary (app.request("/health")). The README’s Schema section shows how to callgraphql({ schema, rootValue, source })directly from a test — useful when you want to exercise resolver logic without spinning up SQLite. Pair it withvi.mock("../Db.res.mjs", ...)for pure unit tests.Server.resnever callsserve()at module top level — that work lives instart()and is invoked fromServerMain.res. Tests can thereforeimport("../Server.res.mjs")without binding port 4000.failwithin resolvers becomes a GraphQL error. graphql-yoga catches the throw, walks the path, and emits anerrors[]entry instead of a 500. Usefailwithfor client-visible validation; reserveHono.onErrorfor unexpected exceptions in non-yoga routes (the global handler returns a JSON 500).Defaults to a local SQLite file.
DATABASE_URL=file:./data/app.dbis the out-of-the-box wiring. Swap it for alibsql://URL (Turso) without touching code — the libsql client speaks both protocols..env.exampledocuments the variable.Node 24 only.
engines.nodeis>=24and.nvmrcsays24. Earlier majors are not exercised by CI.Generated artifacts are gitignored:
data/,drizzle/(migration SQL),docs/schema.md, and.env.