enodia
v0.16.2
Published
Enodia is a GraphQL client and server generator for Typescript projects. It generates fully typed client and server files from your GraphQL schema, allowing you to have automatic types in return of your queries and mutations, type safety when providing ar
Readme
Enodia
Enodia is a GraphQL client and server generator for Typescript projects. It generates fully typed client and server files from your GraphQL schema, allowing you to have automatic types in return of your queries and mutations, type safety when providing arguments and fields, and a fully typed server to ensure your resolvers return the expected values.

Installation
As Enodia is needed only to generate the files, you can install it as a dev dependency:
npm install -D enodiaFinally, you will need to setup an enodia.config.ts file at the root of your
project. Wrap your configuration with defineConfig to get autocompletion and
type checking:
import { defineConfig, scalar } from "enodia";
export default defineConfig({
// This is either the path to your graphql schema, or the URL where your API runs
schema: "./src/graphql.schema",
// If you only want to generate the server side, you can omit this
client: {
// This is the path where you want the client to be generated
path: "./src/web/enodia.ts",
},
server: {
// This is the path where you want the server to be generated
path: "./src/server/enodia.ts",
},
// If you use custom scalars, you have to define them here
scalars: {
// Map each custom scalar in your GraphQL schema to a Typescript type with
// `scalar<YourType>()`. For example, a `Date` scalar mapped to `Date`:
Date: scalar<Date>(),
},
});More information on this file in the configuration section.
Usage
Generating the client
Once your enodia.config.ts file is setup, you can simply run Enodia:
npx enodiaThis will generate a client and a server file at the given paths. You should .gitignore the generated file.
Using the client
The generated file exports a simple enodia function, which takes the URL of
your API as a first parameter. The second parameter is a configuration object,
allowing you to inject a custom fetch function. This can be used to handle
authentication, for example. This function will return the instantiated client.
The client has two properties, query and mutation containing functions to
call every query and mutation your GraphQL API exposes.
Fetch policy
The configuration object accepts a fetchPolicy that controls how the client's
in-memory cache is used.
const client = enodia("https://api.example.com/graphql", {
fetchPolicy: "cache-first",
});The available policies are:
network-only(default): always fetches, and stores the result in the cache.cache-first: returns the cached result if there is one, otherwise fetches and stores it.no-cache: always fetches, and never reads from or writes to the cache.cache-only: returns the cached result if there is one, otherwise throws without ever hitting the network.
The default is network-only, so the client keeps fetching unless you opt into
caching. You can change this default with the fetchPolicy
config option. The policy is set once per client.
Normalized cache
The cache is normalized by entity. Any object type that exposes an id
field is stored once under a Type:id key, and Enodia automatically requests
id (and __typename on unions) for those types even when you did not select
them — it strips them back out of the result so you still get exactly the fields
you asked for. Because every query points at the same stored entities:
- Fetching
user(id: "1") { name }thenuser(id: "1") { email }leaves the cachedUser:1holding both fields, so a later{ name, email }read is a cache hit. - A mutation that returns an entity updates it in place, so queries already holding that entity see the new values without refetching.
A query that returns a single entity through one ID argument (like
user(id: ID!): User) is also served straight from the entity store: if a
users query already loaded User:2, then user(id: "2") is a cache hit even
though that exact query never ran. This redirect only applies to ID-typed
arguments, and still falls back to the network if the cached entity is missing a
requested field.
The cache is shared across every client created from the generated file and lives for the lifetime of the process. One thing it does not do yet: lists are not invalidated when a mutation creates or deletes a member.
When you generate React hooks, each query hook takes a trailing options object
with skip and fetchPolicy, the latter defaulting to network-only:
const [loading, error, user] = useUserQuery(
["id", "name"],
{ id },
{ fetchPolicy: "cache-and-network" },
);loading is true whenever a request for the current query/args is in flight,
including on refetches triggered by a query or args change (e.g. paginating by
changing offset) — not just on the very first request. For every fetch policy
except cache-and-network, loading stays mutually exclusive with the result:
data is null for the whole in-flight (or errored) period, exactly like
before, so const [loading, error, user] = useUserQuery(...) narrows user to
null whenever loading or error is set.
Hooks additionally support cache-and-network: the hook shows cached data
immediately (if any) and then refetches from the network, updating when the
response arrives. A cache hit resolves loading to false right away — no
skeleton flash for an already-cached key — while a cache miss keeps loading
true until the network request settles. Because the whole point of this policy
is to keep showing the previous result while revalidating, its hooks return a
different, slightly looser shape: data can be non-null at the same time as
loading or error, so consumers can choose stale-while- revalidate over a
skeleton. TypeScript picks the right shape for you based on the fetchPolicy
you pass — pass fetchPolicy: "cache-and-network" and user above is typed
{ name: string } | null even while loading is true; omit it (or pass
anything else) and user stays null in that case. This policy only exists on
the hooks — the plain client's call resolves a single promise, so it has no
way to emit twice.
Unions
Fields returning a union are selected with a $on object keyed by the member
type names. __typename can be selected directly and is used to discriminate
the result:
const feed = await client.query.feed([
"__typename",
{
$on: {
Post: ["id", "content"],
Comment: ["id", { author: ["name"] }],
},
},
]);
feed.forEach((item) => {
if (item.__typename === "Post") {
// item is narrowed to the Post selection here
console.log(item.content);
}
});On the server, resolvers for a union-typed field must return the matching member
tagged with its __typename so the server can resolve the concrete type.
Subscriptions
Add a Subscription root type to your schema and Enodia generates a
subscriptions transport for both the client and the server, speaking the
graphql-ws protocol
(graphql-transport-ws). This needs a couple of extra packages that are not
installed by default, since not every project needs subscriptions:
npm install -D graphql-ws ws @types/wsServer
A subscription resolver returns an AsyncIterable of the field's return type —
Enodia handles wrapping each yielded value into the right GraphQL shape. Enodia
doesn't ship a pub/sub of its own (the same way it doesn't ship a database
client for Query/Mutation resolvers): pair it with
graphql-subscriptions's
PubSub, a small, well-known helper that does exactly this job:
npm install graphql-subscriptionsimport { PubSub } from "graphql-subscriptions";
const pubsub = new PubSub();
const MESSAGE_ADDED = "MESSAGE_ADDED";
export const { listener, subscriptionServer } = server()(fieldsConfiguration)({
Query: {
messages: (args: { roomId: string }) => messagesByRoom[args.roomId] ?? [],
},
Mutation: {
sendMessage: async (args: { roomId: string; text: string }) => {
const message = { id: randomUUID(), text: args.text, roomId: args.roomId };
(messagesByRoom[args.roomId] ??= []).push(message);
await pubsub.publish(MESSAGE_ADDED, message); // publish the entity itself
return message;
},
},
Subscription: {
messageAdded: async function* (args: { roomId: string }) {
// `PubSub` fans every publish out to every subscriber on the topic;
// per-argument filtering (here, by roomId) is on you.
for await (const message of pubsub.asyncIterableIterator<Message>(MESSAGE_ADDED)) {
if (message.roomId === args.roomId) yield message;
}
},
},
});PubSub on its own is in-process only — a mutation handled by one server
instance won't reach clients connected to another. For anything running more
than one instance, swap it for a broker-backed pub/sub (Redis, Postgres
LISTEN/NOTIFY, etc.); the resolver shape above doesn't change, since Enodia
only cares that you hand back an AsyncIterable.
Once the schema has a Subscription root, server(...) returns
{ listener, subscriptionServer } instead of a plain request handler —
listener is that same HTTP handler, and subscriptionServer wires
graphql-ws onto a
ws WebSocketServer you construct
yourself:
import { createServer } from "http";
import { WebSocketServer } from "ws";
const httpServer = createServer(listener);
const webSocketServer = new WebSocketServer({ server: httpServer, path: "/graphql" });
const dispose = subscriptionServer(webSocketServer);server's options gain a second, WS-specific context function alongside
instantiateContext, since a WebSocket upgrade has no ServerResponse and
carries the client's connection_init payload instead:
server({
instantiateContext: (request, response) => /* ... */,
instantiateSubscriptionContext: (request, connectionParams) => /* ... */,
})(/* ... */);With permissions enabled, a subscribe resolver returns
Authorized<AsyncIterable<T>> | Denied — the check runs once, before the
stream starts, not per event:
Subscription: {
messageAdded: (args, context) =>
context.canSubscribe
? new Authorized(messagesFor(args.roomId))
: new Denied("You can't subscribe to this room"),
},Client
The generated client exposes a subscription bucket alongside query and
mutation. It takes a selection and args like a query, plus callbacks instead
of returning a promise, and returns an unsubscribe function:
const unsubscribe = client.subscription.messageAdded(
["id", "text"],
{ roomId: "general" },
{ onData: (message) => console.log(message) },
);When you generate React hooks, each subscription field also gets a
useXxxSubscription hook. It subscribes on mount and whenever the
query/args change, and unsubscribes on cleanup:
const [loading, error, message] = useMessageAddedSubscription(
["id", "text"],
{ roomId: "general" },
);Subscription events feed into the same normalized cache queries and mutations use: if an entity a subscription updates is already displayed by a mounted query hook, that hook re-renders with the new value — no manual refetch, and no need to also subscribe from wherever the entity is shown.
By default the WS endpoint is derived from the client's URL by swapping the
protocol (http → ws, https → wss). Override it — for example when
subscriptions run on a different host or path — with wsUrl:
export default defineConfig({
client: {
react: {
url: "http://localhost:3000/graphql",
wsUrl: "wss://realtime.example.com/graphql",
},
},
});The plain client accepts the same override through the options object passed
to enodia(url, options), applied to every subscription made from that
client instance.
Configuration
schema
Enodia needs a GraphQL schema definition to generate the client and server. You can either point to a GraphQL file:
export default defineConfig({
schema: "./path/to/schema.graphql",
});Or provide the URL of your GraphQL API:
export default defineConfig({
schema: "http://localhost:3000/graphql",
});Note that your API will need to be running for Enodia to generate the files.
client
path
If you want to generate the client, you will need to provide its path:
export default defineConfig({
client: {
path: "./path/to/client.ts",
},
});fetchPolicy
The fetch policy is network-only by default. You can change
the default baked into the generated client and hooks here:
export default defineConfig({
client: {
path: "./path/to/client.ts",
fetchPolicy: "cache-first",
},
});This only sets the default; both the client and the hooks still accept a
fetchPolicy per call. You can set the default to cache-and-network, in which
case the hooks use it while the plain client falls back to network-only, since
a single promise can't emit twice.
react
Enodia can also generate React hooks for each query and mutations. All you need for it is to specify the API URL to call:
export default defineConfig({
client: {
react: {
url: "http://localhost:3000/graphql",
},
},
});As the config file is written in Typescript, you can rely on environment
variables. Note that Enodia does not parse dotenv files by default, so you may
need to rely on the dotenv library.
import "dotenv/config";
import { defineConfig } from "enodia";
export default defineConfig({
client: {
react: {
url: process.env.GRAPHQL_API_ENDPOINT,
},
},
});server
path
If you want to generate the server, you will need to provide its path:
export default defineConfig({
server: {
path: "./path/to/server.ts",
},
});permissions
By default, resolvers return their values directly, and it is up to you to
enforce authorization however you see fit. Opting into permissions changes
that: every resolver - root Query/Mutation fields and field resolvers
alike - must wrap its return value in either Authorized or Denied. This is
enforced by the generated types, so a resolver that returns a bare value
instead of one of these two wrappers fails to typecheck, even when the
permission should be unconditionally granted.
export default defineConfig({
server: {
path: "./path/to/server.ts",
permissions: true,
},
});Authorized and Denied are exported from the generated file alongside
buildSchema and server. You decide when to check the permission - before
doing any work, after fetching the data you need to decide, or not at all for
resolvers that are intentionally open:
import { buildSchema, Authorized, Denied } from "./path/to/server.ts";
buildSchema<Context>()({
User: {
email: (user, context) =>
context.userId === user.id
? new Authorized(user.email)
: new Denied("You can only see your own email"),
},
})({
Query: {
users: (context) => new Authorized(getUsers()),
},
Mutation: {
deleteUser: (args, context) =>
context.isAdmin
? new Authorized(deleteUser(args.id))
: new Denied(),
},
});A Denied resolver short-circuits the field: the server throws a
GraphQLError with the given reason (or a generic "Forbidden" message if none
is given) instead of resolving it, and the rest of the query keeps resolving
normally.
scalars
This is a map of custom scalar types in your GraphQL schema to Typescript types.
Each scalar is declared with scalar<YourType>(), so Enodia has you specify the
actual type rather than defaulting to any. When the scalar is already valid
JSON, such as an id carried as a string, that is all you need:
import { defineConfig, scalar } from "enodia";
export default defineConfig({
scalars: {
UUID: scalar<string>(),
},
});The types that need nothing more are the ones that are already valid JSON:
string, number, boolean, null, and arrays or objects built from them, at
any depth. An object whose fields are all JSON works as-is, because it
serializes and comes back as the same value:
scalars: {
Point: scalar<{ x: number; y: number }>(),
},The check is recursive, so a single field that is not JSON, such as a Date,
makes the whole scalar require a codec.
A type that is not valid JSON on its own, such as a Date, cannot be declared
with scalar<Date>() alone. Enodia requires it to carry a codec, described in
Codecs below, so it knows how to convert the value to and from the
wire. Importing a type for a scalar is covered there as well, since such a type
usually needs a codec anyway.
Codecs
A GraphQL scalar always travels over the wire as JSON, so a Date arrives as a
string. To turn that string back into the type you asked for, give the scalar a
codec with an encode and a decode function. encode turns your type into
its wire value, and decode turns a wire value back into your type:
import { defineConfig, scalar } from "enodia";
export default defineConfig({
scalars: {
DateTime: scalar<Date, string>({
encode: (value) => value.toISOString(),
decode: (value) => new Date(value),
}),
},
});The second type argument is the wire type, the shape the scalar has as JSON. It
is optional and defaults to unknown, but giving it lets decode receive a
typed value instead of having to cast it.
Each side then applies the codec in the direction that fits it. On the client,
responses come back as real Date objects, and Date arguments you pass are
encoded to strings before they are sent, including when they are nested inside
input objects. On the server, the same codec encodes the values your resolvers
return and decodes the arguments they receive.
The type and the codec functions are read straight from your config, so they can rely on imports. This is the usual case, since a type worth a codec often comes from a library:
import { defineConfig, scalar } from "enodia";
import Decimal from "decimal.js";
export default defineConfig({
scalars: {
BigDecimal: scalar<Decimal, string>({
encode: (value) => value.toString(),
decode: (value) => new Decimal(value),
}),
},
});Enodia replicates every import the type and its codec depend on into the generated files, rewriting relative paths so they resolve from each generated file's location. Because the imports are replicated instead of copied, the generated files always reference the original, so updating it in its source module is enough and the generated copy never goes stale.
