GraphQL Code generators
Dreaming up the next generation of tools
— 6 minPrompted by a recent meetup with former work colleagues, I had another short look at relay as an alternative to apollo which we currently use and with which we are not quite that happy.
Both libraries have support for typescript code generation, but relay puts it more in the center.
All in all, the problems, or rather improvements and dreams I have come from the fact that strict typing and code generation was added as an afterthought. You can use both libraries a) with untyped JS and b) without code generation at all.
Apart from the very obvious problem that the apollo-cli itself is horrible, both from a usage as well
as a project viewpoint (I mean it pulls in yarn
as a dependency transitively, wtf?), I see
the following problems or lack of features in both relay and apollo when it comes to typescript
support and codegen.
# There is a lot of footguns
Lets create an example slightly changed from the relay docs.
import { ExampleQuery } from "__generated__/ExampleQuery.graphql"
const QUERY = graphql`
query ExampleQuery($artistID: ID!) {
artist(id: $artistID) {
name
}
}
`
<QueryRenderer<ExampleQuery>
query={QUERY}
variables={{ artistID: 'banksy' }}
/>
As this example shows, code generation and typescript support revolves around importing a generated
type definition and manually providing it as optional type parameter to the QueryRenderer
component.
This perfectly demonstrates the two points I made before. Remove the import
and the type parameter,
and the code becomes valid untyped JS.
However, there are a couple of obvious footguns even with this very simple example.
- The type parameter is optional and defaults to
any
. Developers are lazy, and using codegen in reality is a lot more tedious than this example. - The type parameter needs to be provided manually, which means developers could potentially mess up, the typescript compiler will not warn about this.
- One way to mess up is to use mix up the
query
prop with a wrong type parameter. - It is not DRY, I will explain later on.
The example from the apollo docs is even worse, since the results and the variables are provided as two separate type parameters. So there is even more opportunity to mess up.
# Digression: Component-based API
This might just be a personal preference, but I consider Components with a render-prop API a horrible design pattern in general. Especially for usage with graphql, I consider this to be extremely unergonomic and tedious.
Using react hooks
is a hugely better alternative. But true, both relay and apollo are older than the hooks API, so I will
let this one slide.
# Types are mis-used
One problem I see with the generated types in our codebase is that developers are actually mis-using them. This is not really specific to the way graphql libraries work, but a problem I see in general when it comes to typed react code.
One very good design pattern in react is to split code into presentational components, that just display some data, and container components, which might include some business logic and data fetching logic. In my opinion, a presentational component should itself declare what kind of props it wants to receive. And the type checker makes sure that the container components provides valid props.
Well in reality, developers will just happily import graphql-generated types for use in their presentational components, which I personally consider to be a red flag and a mis-use of those types.
Example:
import { VenueLogoWrapperVenueQuery_venue_logo } from "../container/genTypes/VenueLogoWrapperVenueQuery";
interface LogoWrapperProps {
logo?: VenueLogoWrapperVenueQuery_venue_logo | null;
}
This surely gets a huge facepalm from me.
# It does not support custom scalars
GraphQL itself has a super simple concept. You just provide a query
string and optional variables
and you get back some
json data
. You don’t even need a big-ass library, all you need is fetch
.
But this also means you are limited to datatypes representable as json.
Which means you have null
instead of undefined
, and you don’t have rich types such as Date
.
There is even an old feature request for apollo to generate
undefined
instead of null
, but I think it unlikely for that ever to happen.
For all of the sophistication these libraries have, they are actually quite dumb in this regard. All they do is pass on the result json unaltered.
# A dream takes form
With all these issues in mind, lets brainstorm and dream up an ideal API.
What if, a next generation graphql code generator generated some code like this:
import { getVenue, getMe, requestPasswordReset } from "./some-generated-file";
const venue = await getVenue(conn, { id: "…" });
// venue = { name: string, logo?: Image, … }
const me = await getMe(conn);
// me = { lastLogin: Date, … }
const result = await requestPasswordReset(conn, { email: "…" });
// …
So my proposed next-generation graphql code generator will generate ready to use, strongly typed
async functions, which you can just call with a connection
(essentially a parameterized fetch
,
apollo-client
, or whatever), and some strongly typed variables.
These functions will embed the query string itself, and will do type conversions for both input and
result types, in the example above the Image
and Date
scalars, which would be automatically deserialized
to typescript classes, or enums, or whatever.
This dreamed-up example was not react-specific, but it would be super easy to also create ready to use hooks
which would consume the connection
via context
and thus be even easier to use.
Essentially, it will solve the following problems, plus give even more advantages:
- simple, ready-to-use API
- automatic scalar deserialization
- strongly typed, with no opportunity for mis-use
- DRY, because it embeds the
query
portion right in the function - very friendly to dead-code-elimination / tree-shaking
# Inspiring other projects
Now that I have brainstormed the graphql code generator of my dreams, it also got me thinking about another one of my
projects. Another code generation success story is intl-codegen, which similarly generates ready to use functions out
of MessageFormat
strings, with the goal of both avoiding to ship a MessageFormat
parser to runtime as well as to provide
not quite so strong typing.
I could imagine that instead of having a single Localized
component, it could actually generate a separate component for
each message.
// now:
<Localized id="some-message-id" params={{ name: "some name" }} />
// future?:
<SomeMessageId name="some name" />
I’m not quite convinced yet if this would be a worthwile change, but it certainly would make it impossible to mis-use
the current implementation by using a dynamic id
, which circumvents typing.
In general, I have neglected intl-codegen quite a bit recently, but I do have a lot of ambitious ideas about its future. But those ambitious ideas are also hard to implement so it will take quite some more time to think about those.