Fully typed resolvers

Tom HouléTom Houlé
Fully typed resolvers

We recently released typed resolvers and authorizer arguments in the Grafbase SDK. This was a first stop gap that brought welcome type safety and completion when you work on resolvers.

Today, we release the second part of this effort with a developer experience that is both easier and completely type safe. Full type safety means that all of a resolver function's arguments and its return type have precise, exhaustive TypeScript types.

This change is not breaking: if you already have resolvers, they will keep working just as they did until now. But if you want to opt in to more checks, follow the steps in this post.

Now let's see what a typed resolver looks like. With the following schema:

g.type('Question', { id: g.id(), content: g.string(), getAnswer: g.string().resolver('get-answer'), })

The resolver at resolvers/get-answer.ts can now look like this:

import { Resolver } from '@grafbase/generated' const resolver: Resolver['Question.getAnswer'] = async ( parent, args, { kv }, info, ) => { const { value } = await kv.get(`answers/${parent.id}`) return `⚙️ Result of ${info.fieldName}: ${value}` } export default resolver

Let's get into the details of what is happening here.

The general shape of the resolver did not change: it is still a regular or async function that takes the same four arguments as before, and it must be the default export of the module.

The difference is where we write the type annotation: on the whole function. As you start typing const resolver: Resolver[', your editor will show completions for all available resolvers. The resolver type names are the parent type's name (here Question, from our schema), a dot, and the field name. This is the resolver for the getAnswer field on Question in our schema above, so its type is Resolver['Question.getAnswer']. With this single type annotation, you get precise types for all arguments and the return type.

In the code snippet above, if you hover on the parent argument, you will see it has the type Question. These types — as well as Resolver — are generated when you run grafbase dev and land into a directory called generated in your project's grafbase directory.

You are free to deal with the generated module in any way you like, but we recommend the following approach:

  • Add the grafbase/generated directory to your .gitignore.
  • Add a paths entry to your tsconfig.json so you can import the generated code as @grafbase/generated anywhere in your project.
{ "compilerOptions": { "paths": { "@grafbase/generated": ["./grafbase/generated"] } } }

In addition to the generated types, grafbase dev will analyze your resolvers in the background and report common issues. For example, if you export your resolver, but not as a default export:

🔄 Detected a change in grafbase/resolvers/get-answer.ts, reloading Error: × The module is missing a default export. Grafbase expects a resolver function as default export. ╭─[/home/tom/src/gh/grafbase/grafbase/examples/resolvers-kv/grafbase/resolvers/get-answer.ts:2:1] 2 │ 3 │ ╭─▶ export const resolver: Resolver['Question.getAnswer'] = async (parent, args, { kv }, info) => { 4 │ │ const { value } = await kv.get(`answers/${parent.id}`) 5 │ │ return `⚙️ Result of ${info.fieldName}: ${value}` 6 │ ├─▶ } · ╰──── Maybe this should be the default export? 7 │ ╰──── help: Export your resolver as default: `export default resolver`

Or if your type annotation does not match what is declared in your schema:

× The resolver for `User.creditScore` isn't at the right location. In your schema, the resolver for `User.creditScore` is declared at `nested/somewhere/else`. ╭─[./tests/resolver_discovery/wrong_location/resolvers/nested/wrongLoc.ts:2:1] 2 │ 3 │ const resolver: Resolver["User.creditScore"] = () => 1 · ─────────┬──────── · ╰── Inferred from this. 4 │ ╰──── help: You can move the file or change the resolver name in your schema to "nested/wrongLoc".

Generated types alone are not enough for a good experience, so we bring in additional automation. These checks will be tweaked, possibly expanded in the future, and make for even better developer experience.

There is one part of the experience that is not 100% type safe yet: custom scalars. They are currently given the any type. We will design and implement a solution separately.

We see typed resolvers as described in this changelog post as a good starting point, but we will continue improving the developer experience based on feedback. Your feedback is always appreciated on Discord or any other channel.