Skip to content
TkDodo's blog
TwitterGithub

Type-safe React Query

ReactJs, React Query, TypeScript, JavaScript5 min read

safety helmet

Last Update: 21.10.2023

  • 한국어
  • Add translation

I think we can all agree that using TypeScript is a good idea. Who doesn't like type-safety? It's a great way to catch bugs early on, and it allows us to offload some complexity of our apps to the type definitions so that we don't have to keep them in our heads forever.

The level of type-safety can drastically vary from project to project. After all, every valid JavaScript code can be valid TypeScript code - depending on the TS settings. And there is also a big difference between "having types" and "being type-safe".

To truly leverage the power of TypeScript, there is one thing that you need above all:

Trust

We need to be able to trust our type definitions. If we don't, our types become a mere suggestion - we can't rely on them to be accurate. So we go above and beyond to make sure we can trust them:

  • We enable the strictest of TypeScript settings.
  • We add typescript-eslint to forbid the any type as well as ts-ignore.
  • We point out all type assertions in code reviews.

And still - we are probably lying. A LOT. Even if we adhere to all the above things.

Generics

Generics are essential in TypeScript. As soon as you want to implement something remotely complex, you will have to reach for them - especially when you're writing a reusable library.

However, as a user of a library, you ideally shouldn't need to care about their Generics. They are an implementation detail. So whenever you provide a generic "manually" to a function via the angle brackets, it's kinda bad for one of two reasons:

It's either unnecessary, or you're lying to yourself.

About angle brackets

Angle brackets makes your code look "more complex" than it has to be. As an example, let's look at how useQuery is often written:

useQuery-with-angle-brackets
1type Todo = { id: number; name: string; done: boolean }
2
3const fetchTodo = async (id: number) => {
4 const response = await axios.get(`/todos/${id}`)
5 return response.data
6}
7
8const query = useQuery<Todo>({
9 queryKey: ['todos', id],
10 queryFn: fetchTodo,
11})
12
13query.data
14// ^?(property) data: Todo | undefined

The main problem here is that useQuery has four generics. By providing only one of them manually, the other three fall back to their default values. You can read about why that's bad in #6: React Query and TypeScript.

Just to be on the same page - axios.get returns any (just like fetch would, but ky does this slightly better by giving us unknown back per default). It doesn't know what the /todos/id endpoint will return. And because we don't want our data property to be any as well, we have to "override" the inferred generic by providing it manually. Or do we?

The better way is to type the fetchTodo function itself:

typed-fetchTodo
1type Todo = { id: number; name: string; done: boolean }
2
3// ✅ typing the return value of fetchTodo
4const fetchTodo = async (id: number): Promise<Todo> => {
5 const response = await axios.get(`/todos/${id}`)
6 return response.data
7}
8
9// ✅ no generics on useQuery
10const query = useQuery({
11 queryKey: ['todos', id],
12 queryFn: () => fetchTodo(id),
13})
14
15// 🙌 types are still properly inferred
16query.data
17// ^?(property) data: Todo | undefined

Now with this, React Query can properly infer what data will be from the result of the queryFn. No need for manual generics. If the input to useQuery is sufficiently typed, you will not have to add angle brackets to it. 🎉

Lying angle brackets

Alternatively, we can also tell our data fetching layer, in this case axios, what the expected type is by providing the Generics via angle brackets there:

providing-generics
1const fetchTodo = async (id: number) => {
2 const response = await axios.get<Todo>(`/todos/${id}`)
3 return response.data
4}

Now we don't even have to type the fetchTodo function if we don't want to because type inference will again work for us here. Those generics are not unnecessary per se, but they are a lie because they violate the golden rule of Generics.

The golden rule of Generics

I learned this rule from @danvdk's great book Effective TypeScript. It basically states:

For a Generic to be useful, it must appear at least twice.

The so called "return-only" generics are nothing more than a type assertion in disguise. The (slightly simplified) type signature for axios.get reads:

axios-get-type-signature
1function get<T = any>(url: string): Promise<{ data: T, status: number}>

The Type T only appears in one place - the return type. So it's a lie! We could've just as well written:

explicit-type-assertion
1const fetchTodo = async (id: number) => {
2 const response = await axios.get(`/todos/${id}`)
3 return response.data as Todo
4}

At least this type assertion (as Todo) is explicit and not hidden. It shows that we are bypassing the compiler, that we are getting something unsafe and trying to turn it into something we can trust.

Trust again

And now we are back to trust. How can we trust that what we're getting over the wire is in fact of a certain type? We cannot, and maybe that's okay.

I used to refer to this situation as a "trusted boundary". We have to trust that what the backend returns is what we have agreed upon. If it's not, this isn't our fault - it's the fault of the backend team.

Of course, the customer doesn't care. All they see is "cannot read property name of undefined" or something similar. Frontend devs will be called into the escalation, and it will take us quite a bit of time to actually figure out that we're not getting the right shape of data over the wire, because the error will appear in a completely different place.

So is there something that we can do to give us trust?

zod

zod is a beautiful validation library that lets you define a schema you can validate against at runtime. On top of that, it infers the type of the validated data directly from the schema.

This basically means that instead of writing a type definition and then asserting that something is that type, we write a schema and validate that the input conforms to that schema - at which point it becomes that type.

I first heard about zod when working with forms. It makes total sense to validate user input. As a nice side effect, the input will also be typed correctly after the validation. But we can not only validate user input - we can validate anything. Url params for example. Or network responses...

validation in the queryFn


parsing-with-zod
1import { z } from 'zod'
2
3// 👀 define the schema
4const todoSchema = z.object({
5 id: z.number(),
6 name: z.string(),
7 done: z.boolean(),
8})
9
10const fetchTodo = async (id: number) => {
11 const response = await axios.get(`/todos/${id}`)
12 // 🎉 parse against the schema
13 return todoSchema.parse(response.data)
14}
15
16const query = useQuery({
17 queryKey: ['todos', id],
18 queryFn: () => fetchTodo(id),
19})

This isn't even more code than before. We've basically exchanged two things:

  • the manual type definition of the Todo type with the todoSchema definition.
  • the type assertion with the schema parsing.

This plays so well together with React Query because parse throws a descriptive Error if something went wrong, which will make React Query go into error state - just as if the network call itself failed. And from the client perspective - it did fail, because it didn't return the expected structure. Now we have an error state that we need to handle anyway, and there will be no surprises for our users.

It also goes nicely with another guideline of mine:

The more your TypeScript code looks like JavaScript, the better.

Apart from id: number, there isn't a single thing that differentiates this TS code from JS. There is no added TypeScript complexity - we just get the benefits of type-safety. Type inference "flows" through our code like a hot knife through butter. 🤤

Tradeoffs

Schema parsing is a great concept to be aware of, but it's not for free. For starters, your schemas should be as resilient as you want them to be. If it doesn't matter that an optional property is null or undefined at runtime, you might create a miserable user experience if you fail the query because of something like that. So design your schemas resiliently.

Also, parsing does come with an overhead, as data must be analyzed at runtime to see if it fits the required structure. So it might not make sense to apply this technique everywhere.

What about getQueryData

You might have noticed that queryClient.getQueryData suffers from the same problem: It contains a return-only generic, and it will default to unknown if you don't provide it.

getQueryData-generic
1const todo = queryClient.getQueryData(['todos', 1])
2// ^? const todo: unknown
3
4const todo = queryClient.getQueryData<Todo>(['todos', 1])
5// ^? const todo: Todo | undefined

Since React Query cannot know what you put into the QueryCache (as there is no up-front defined overall schema), this is the best we can do. Of course, you can also parse the result of getQueryData with a schema, but this isn't really necessary if you've validated the cached data before. Also, direct interactions with the QueryCache should be done sparingly.

Tools on top of React Query, like react-query-kit, do a great job at alleviating the pain, but they can only go so far and basically hide the lie a bit more for you.

End-to-end type-safety

While there isn't a lot more that React Query can do for us in this regard, there are other tools that can. If you are in control over both your frontend and backend, and if they even live in the same monorepo together, consider using tools like tRPC or zodios. They both build on top of React Query for the client-side data fetching solution, but they both have what it takes to become truly type-safe: an upfront API / router definition.

With that, types on the frontend can be inferred from whatever the backend produces - without a chance of being wrong. They also both use zod for defining the schema (tRPC is validation library agnostic, but zod is the most popular), so learning how to work with zod could definitely go on your list to learn for 2023. 🎊


That's it for today. Feel free to reach out to me on twitter if you have any questions, or just leave a comment below. ⬇️