41.3 Apollo Client with TypeScript: Typed Queries and Mutations
Right, let’s get you out of the “any” zone and into the promised land of type safety. You’ve set up your GraphQL API, you’ve got your code generator humming along producing beautiful, precise TypeScript types from your schema. Now comes the fun part: actually using them without throwing all that hard work out the window. Apollo Client is our vehicle, and TypeScript is our navigation system. Buckle up.
The magic trick here is that we’re not just typing the data we get back; we’re typing the entire operation—the variables we send, the shape of the response, and even the hooks themselves. This turns your IDE from a dumb text editor into a brilliant assistant that actively prevents you from making a fool of yourself.
The Setup: Wrapping Apollo Client
First, we need to tell Apollo Client about our types. This happens when you create the client instance. The key is extending the ApolloClient class from @apollo/client to use our generated types. I like to create a dedicated file for this, because organization is what separates us from the animals.
// lib/apolloClient.ts
import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import type { NormalizedCacheObject } from '@apollo/client';
// This is the important bit - import your generated types
import type { PossibleTypesMap } from './gql/graphql'; // Adjust the path
// This is a cheeky way to get your possibleTypes for cache introspection
// if you're using fragments on unions/interfaces. Not strictly necessary but highly recommended.
const possibleTypes: PossibleTypesMap = {
// This is auto-generated by your codegen! Usually a file called possibleTypes.json
// You import it or define it manually.
};
export const createClient = (): ApolloClient<NormalizedCacheObject> => {
const httpLink = createHttpLink({
uri: 'https://your-api.graphql.app',
});
const authLink = setContext((_, { headers }) => {
// ... your auth logic here
return {
headers: {
...headers,
authorization: `Bearer ${token}`,
},
};
});
return new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache({
possibleTypes, // Plug in the possibleTypes for proper cache behavior
typePolicies: {
// ... your cache policies
},
}),
});
};
Writing a Fully Typed Query
Now for the main event. Let’s say you have a query to fetch a user. You’ve run your codegen, so you have a beautifully typed function called useGetUserQuery (or similar, depending on your config). The generated hook is a thing of beauty. It understands everything.
// components/UserProfile.tsx
import { useGetUserQuery } from '../gql/graphql'; // Import the generated hook
const UserProfile = ({ userId }: { userId: string }) => {
// This is where the magic happens. Look at your IDE's intellisense now.
const { data, loading, error } = useGetUserQuery({
variables: {
id: userId, // Try changing `id` to `ID` or passing a number. Go on, I dare you.
},
});
if (loading) return <div>Hold your horses...</div>;
if (error) return <div>Well, this is embarrassing: {error.message}</div>;
if (!data?.user) return <div>User? What user?</div>;
// Data and user are now fully typed! Autocomplete for days.
return (
<div>
<h1>{data.user.name}</h1>
<p>{data.user.email}</p>
{/* Try typing `data.user.phoneNumb` – see it fail and suggest the right property? */}
</div>
);
};
The beauty here is that Apollo and TypeScript are now working in concert. If your GraphQL schema changes and the User type no longer has a name field, your codegen will fail, and this component will show a type error at build time instead of breaking for your users at runtime. This is the whole point. You’ve just moved a whole class of bugs from production to your laptop.
The Power of Typed Mutations
Mutations work exactly the same way, and frankly, they’re even more important. Sending malformed data to your API is often worse than just reading it wrong.
// components/UpdateUserForm.tsx
import { useUpdateUserMutation } from '../gql/graphql';
const UpdateUserForm = ({ userId }: { userId: string }) => {
// Note the tuple pattern: [mutateFunction, result object]
const [updateUser, { data, loading, error }] = useUpdateUserMutation();
const handleSubmit = (formData: FormData) => {
updateUser({
variables: {
input: {
id: userId,
name: formData.get('name'), // This is a string | null
// The generated type for `input` will likely expect a string.
// This will cause a type error if strict null checks are on. Good!
},
},
}).then((result) => {
// result.data?.updateUser?.email – it's all typed, even the nested response.
});
};
// ... form JSX
};
Common Pitfalls and How to Avoid Them
The Missing
PossibleTypes: If you use unions or interfaces and you don’t provide thepossibleTypesmap toInMemoryCache, the cache won’t be able to correctly store and retrieve normalized objects for those types. Your data will seem to vanish into thin air. It’s a nightmare to debug. Just provide the map.The Overly Eager
fetchPolicy: UsingfetchPolicy: 'cache-and-network'is great for a snappy UX, but if you haven’t structured your queries and cache IDs properly, you can end up with a flash of stale content. Understand the fetch policies; don’t just cargo-cult them.Ignoring the
skipOption: Theskipoption on queries is brilliantly typed. Use it to conditionally avoid a query.useQuery({ variables: { id: someId }, skip: !someId })prevents a doomed query for an invalid ID and saves you from annoyingnullvariable errors.Not Typing the Cache: This is advanced but worth mentioning. You can (and should) type your
typePoliciesin theInMemoryCacheconfiguration. This tells TypeScript what fields exist on your types for customreadandmergefunctions. It’s a bit more manual but prevents you from making up field names inside your cache logic.
The designers at Apollo made a questionable choice by not baking this typed cache configuration in more deeply, but we work with what we have. The overall payoff of typed queries and mutations is so profound that you’ll wonder how you ever built software without it. It’s not just about preventing errors; it’s about moving faster with confidence, and your IDE finally shutting up with all those red squiggles.