How I discovered end-to-end type safety with GraphQL + Typescript
Recently, I’ve decided to go all in on two things, Typescript and GraphQL, after spending most of my coding career building RESTful APIs and avoiding types using languages like Python, Ruby, PHP (before type hints), and plain old Javascript.
So, why the change?
TypeError: Cannot read property of undefined
While recently working on a sizable codebase written in plain JS + React, the most common error was our friendly neighborhood TypeError . This would almost always surface as a result of a front-end developer trying to navigate a deeply nested object provided by our REST API. The result was a lot of frustrating, unexpected behavior or crashes(we definitely could have done a better job at error handling).
There were a few reasons for this:
- The front-end developers did not have a deep understanding of our APIs data models. This wasn’t their fault — we lacked documentation and our APIs were not designed consistently.
- We used a CMS (Contentful) that returned lots of unstructured data whose structure could be changed by external sources (marketing, product, etc).
- It was the responsibility of our front-end developers to use safe object access methods and manual type-checking when accessing data from our APIs, which was difficult to enforce as a standard practice.
While you can fix these things with non-technical solutions, they had caused me enough frustration that I wanted to bake a solution into my stack that didn’t require any process or developer responsibilities.
Creating predictability and understandability in my APIs
tldr; Write mutations that do one thing, add descriptions, use introspection as documentation
The first issue, empowering front-end engineers to easily understand our APIs, I’ve decided to fix by using GraphQL. The old programming meme of self-documenting code is sorta true here — GraphQL has a lot of things that make it inherently easy to figure out.
Mutations can be written to do one, obvious, and specific thing
In our REST world, if you wanted to do something like update a subscription interval you would issue a POST:
PUT /api/subscriptions/:id
{
interval: 28,
interval_units: 'days'
}
Underneath this request, the API would have to recognize that the interval was changing and then dispatch the proper action to handle an interval change.
You can use the same API to do something like cancel a subscription:
PUT /api/subscriptions/:id
{
state: 'canceled'
}
An argument can be made that, from a front-end developers' POV, this is simple — they only have to remember a single API, and this is valid.
However, in practice, I’ve found that it's difficult to document and educate what each attribute of a RESTful resource can do when you change it. For example, how does a front-end developer know what states there are to change when a certain state change is valid, and what happens after a state change is completed?
You could pop all of this into your API documentation, but I find that to be clunky and difficult to maintain.
Instead, having mutations that do specific things helps bridge the gap with a very clear thing:
mutation ChangeSubscriptionInterval($id: ID!, $days: Integer!, $interval: SubscriptionInterval) {
changeSubscriptionInterval(id: ID, days: $days, interval: $interval) {
id
days
interval
updatedAt
}
}
mutation CancelSubscription($id: ID!) {
cancelSubscription(id: $id) {
id
state
updatedAt
}
}
This is great, because:
- The intent of each operation is clear and someone can understand what the potential side effects are (e.g CancelSubscription will…. cancel a subscription).
- The parameters and their types are clearly documented, right in the code. No additional documentation is needed to clarify intent.
Introspection, descriptions, and auto-completion
When you are writing new queries or mutations to integrate into your front-end code, you have the ability to run introspection queries against your GraphQL schema.
With these introspections, you can see:
- What queries and mutations are available
- What input arguments are available
- What types can be returned
If you’re lucky, your back-end engineers can write descriptions for every field returned, giving you additional commentary.
Tools like Postman and Apollo Studio will run introspections in real-time, providing cool stuff like auto-complete as you write your queries too.
Creating safety with types
tldr; Generated types from your GraphQL schema == 😍
I think my aha moment came when I discovered graphql-codegen and started using it to generate types for my GraphQL schema. This has led to an insane amount of productivity and stability in my development cycles.
For me, it works like this:
- Write my types.
- Add my query or mutation to the API.
- Write the query in a file operations.graphql
- Run graphql-codegen
- End up with auto-generated .ts the file containing my types.
At the end of all of this, I have access to all the types, queries, and mutations in my API:
// operations.graphql
query getSubscriptionById($id: ID!) {
subscription(id: $id) {
id
}
}
// app.tsx
import { Subscription, useGetSubscriptionById } from 'graphql/api'
const { data: Subscription } = useGetSubscriptionById(id);
subscription.id // <-- fully typed!
There are a bunch of ways to configure graphql-codegen which their website has great documentation on.
So what?
Overall, I’m at the point where I vastly prefer this setup, namely because of the reasons above. For my style of development, I feel like it leads to me being able to write code with more confidence resulting in faster and safe cycles.
- Do you need to use GraphQL? No.
- Do you need end-to-end types? I do!
I’m excited to explore this concept further, especially with stuff like tRPC gaining traction.