Why we added TypeScript configuration support

TypeScript Configuration

We're excited to announce a new way to configure Grafbase projects using the TypeScript programming language. It enables developers to provide an easy way to define the database schema together with the different connectors and authentication modules. By using TypeScript, we utilize its robust type system, allowing the user to find problems in the schema definition immediately either with the compiler or through the TypeScript language server, without needing to deploy the schema or start the command-line interface just to see if the schema is valid.

In this article we go through some of the design decisions, and hopefully can show you the ways to make your schema definition robust, readable and easy to extend.

Grafbase projects have until now been configured using a GraphQL schema file. It includes the data model to external actors, such as an authentication provider, or an OpenAPI connector. This is called schema-first design, and is a common way of defining a GraphQL schema. A typical schema in the beginning of a project has a few models, defining data types and relations:

type Post @model { title: String! slug: String! @unique comments: [Comment!]! } type Comment @model { post: Post! body: String! }

The example above includes posts and comments, the field types are strictly defined, and the relation between the Post and Comment is loud and clear: a Post has many comments, and a Comment must belong to a blog.

Just store the definition to your in $PROJECT/grafbase/schema.graphql, and when you run grafbase dev or deploy the schema to production, the generated API lets you to store posts and comments to the database.

Today we’d like to announce a new approach to creating a schema. For many people, the overhead of learning multiple programming languages can be difficult, and if your project is already written in TypeScript, having the schema defined in the same language can be beneficial. We can take the GraphQL schema from above, and rewrite it to the following TypeScript definition:

import { config, graph } from '@grafbase/sdk' const g = graph.Standalone() const post = g.model('Post', { title: g.string(), slug: g.string().unique(), comments: g.relation(() => comment).list(), }) const comment = g.model('Comment', { post: g.relation(post), body: g.string(), }) export default config({ graph: g })

Let’s see step-by-step what happens here. We introduced a new TypeScript library, @grafbase/sdk, which provides the needed APIs for schema generation. It’s available from the NPM registry, and can be installed to your project in two different ways.

For new projects, the latest Grafbase CLI lets the user choose the configuration format. After running grafbase init and choosing TypeScript, a new package.json will be created and @grafbase/sdk added in the devDependencies section, and a template grafbase/grafbase.config.ts is added to the project. All you need is to install the dependency with either npm, yarn or pnpm, and you’re ready to go.

To switch to the new configuration format in an existing project, first install the @grafbase/sdk with your package manager of choice. Once the above is done, you can create a new TypeScript configuration file and recreate your current schema definition.

In our example above, we imported two items from @grafbase/sdk:

import { config, graph } from '@grafbase/sdk'
  • graph is the graph object, modifying it will change what data is included in the final API schema.
  • config is the complete configuration, which has to be exported from the grafbase.config.ts file.
const g = graph.Standalone() const post = g.model('Post', { title: g.string(), slug: g.string().unique(), comments: g.relation(() => comment).list(), })

First is the Post model. The structure follows the GraphQL counterpart closely, but adds the TypeScript ecosystem to help you define the schema:

We see all the attributes we can add to the field in the completion list, and when defining the default value, it doesn’t let us use anything except a string as the value. With the TypeScript language server, we made it much simpler and much faster to iterate on your schema design.

const comment = g.model('Comment', { post: g.relation(post), body: g.string(), })

In the Comment model, we create a relation to the Post. You’ll notice a difference in the syntax, in the Post we wrote a lambda function as the relation:

g.relation(() => comment).list()

But in the Comment the lambda is not required:

g.relation(post)

This is due to JavaScript Hoisting, meaning that in the first pass the interpreter sees a lambda, but doesn’t look inside. We can use comment here even when it’s not defined yet. The first pass then finds the comment definition, and when we finally call the lambda function, we know what the variable is and can use it.

The lambda is also useful for self-relations:

const comment = g.model('Comment', { siblingComment: g.relation(() => comment).optional(), })

Make sure not to create an endless loop here, keeping the field optional allows us to end the relation cycle to a null value.

Finally we export the config, so the API can be generated from it correctly:

export default config({ graph: g })

Under the surface, we evaluate the configuration, and generate a GraphQL SDL file for the system to use. But as we can see, using a full-fledged programming language with a strong type system can make the schema design quicker and easier, if you combine it with the full TypeScript toolset.

The type system lets us to prevent easy-to-make mistakes in your schema. In the next example we have an enum definition of Color with three variants: Red, Green and Blue:

const color = g.enum('Color', ['Red', 'Green', 'Blue']) g.model('TrafficLight', { color: g.enumRef(color), })

Now, the default value has to be one of the variants; any other value will lead to a compilation error:

Defining complex authentication rules can be tricky. By using a common lambda function and a rules builder, building authentication rules can be simplified a lot.

A language server, together with a good type system helps our users to use our existing and new features we introduce in Grafbase. Together with the documentation, it allows easier and faster experimentation.

It’s not uncommon for a database schema to expand into multiple thousand lines of code. Eventually the file gets hard to maintain, and modularizing a GraphQL schema definition can be tricky. TypeScript provides a robust module system, which allows splitting your code into multiple files, generating instances of the separate pieces of the code, which can be combined to a complete program. Modularity is one of the key reasons we believe in the code-first approach.

In the following example, we generate a grafbase/models directory together with a file per model. First grafbase/models/comment.ts:

import { graph } from '@grafbase/sdk' import { post } from './post' const g = graph.Standalone() export const comment = g.model('Comment', { post: g.relation(post), body: g.string(), })

Then grafbase/models/post.ts:

import { graph } from '@grafbase/sdk' import { comment } from './comment' const g = graph.Standalone() export const post = g.model('Post', { title: g.string(), slug: g.string().unique(), comments: g.relation(comment).list(), })

As we can see, the models can be shared between files, imported from other modules and used as part of the configuration definition. Finally it is important to import all the files in the main configuration in grafbase/grafbase.config.ts:

import { config, graph } from '@grafbase/sdk' // Require all files generating the config here. import './models/comment' import './models/post' const g = graph.Standalone() export default config({ graph: g })

As long as all the files are imported in the main configuration, they will be part of the generated final schema. When your project evolves and grows, modularizing the schema gets more and more important from a code quality and extensibility point of view.

Some of you might ask, is Grafbase going to stop supporting the traditional schema-first GraphQL configuration? There are many reasons to continue using GraphQL schemas. For example, the application might not be written in TypeScript, and everybody in the project already knows how to write GraphQL. In this scenario learning a new programming language just for Grafbase can be too much to ask. Also, using the TypeScript configuration requires the project to at least use NPM to manage dependencies. Installing new complex tooling just for the configuration might not be worth the complexity.

Therefore Grafbase will continue to support both approaches: code-first and schema-first. If the project’s grafbase directory has a file named grafbase.config.ts, it will be chosen as the configuration. Otherwise we’ll continue loading the schema.graphql file as before. Ultimately, internally we still generate a GraphQL schema from the TypeScript configuration, and all of our systems use it as their main source of truth. With TypeScript, if already invested in the ecosystem, one can utilize the powerful tooling to help them with the schema design.

We are not stopping here with TypeScript. The new configuration format will be a first-class citizen in our development process: every new feature will be available for both types of configuration.

The next thing we’ll be adding to the @grafbase/sdk library is a TypeScript client generator. Our vision is to have a new command: grafbase generate, which reads either the TypeScript configuration or the GraphQL schema, and generates a full-fledged client with corresponding types. This means we’ll be utilizing the language server autocomplete and compiler type-checks end to end: from the schema definition to the application code, all connectors included. When running grafbase dev, every change in the schema will be directly reflected in your editor in real-time.

Stay tuned for some exciting announcements in this space very soon.

Get Started

Build your API of the future now.