Precisely Typed React Components with GraphQL Fragments
If you’re using Apollo Client and GraphQL Code Generator, you can go beyond just typing your queries - you can make each component precisely typed to the fields it actually consumes.
This post shows how to colocate GraphQL fragments with components in React, extract their types, and guarantee end-to-end type safety: from the GraphQL schema, through the page query, down to the component props.
The problem
It’s common to type components using the entire GraphQL type, like this:
import { Product } from '@lib/graphql/codegen/graphql';
type ProductCardProps = {
product: Product; // Full GraphQL type
};
This works, but it creates two issues:
-
The page that renders the component often queries selecting only a subset of fields of
Product
. Once you pass the product to your component, there will be a type mismatch because the component expects all fields, so you’re forced to cast withas Product
. This loses type safety: if you forgot to select a field that the component needs, TypeScript won’t catch it. -
When testing or mocking, you need to provide all fields of
Product
- even the ones the component doesn’t use.
What we really want is for each component to declare its own data contract.
Colocated fragments as contracts
With GraphQL Codegen, fragments are strongly typed. We can colocate a fragment next to a component, describing exactly the fields it requires:
import { gql } from "@lib/graphql/codegen";
import { GraphqlExtract } from "@lib/typeUtils";
/**
* To render a product card, these fields must be selected
*/
export const ProductCardFragment = gql(`
fragment ProductCardFields on Product {
id
name
url
availability
price
}
`);
export type ProductCardProduct = GraphqlExtract<typeof ProductCardFragment>;
Notice the helper type:
import { TypedDocumentNode } from "@apollo/client";
/**
* Given a TypedDocumentNode, extract its result type.
*/
export type GraphqlExtract<D> = D extends TypedDocumentNode<infer T, any>
? T
: never;
This little utility pulls out the data type from a TypedDocumentNode
, so our fragment’s TypeScript type matches exactly what the GraphQL fragment defines.
The ProductCardProduct
type in that case is automatically generated as:
{
id: string;
name: string;
url: string;
availability: string;
price: number;
}
A strongly typed component
Now we can type our component using only the fields from its fragment:
type ProductCardProps = {
product: ProductCardProduct;
};
export function ProductCard({ product }: ProductCardProps) {
return (
<div>
<h3>
<a href={product.url}>{product.name}</a>
</h3>
<p>${product.price.toFixed(2)}</p>
{product.availability === "IN_STOCK" && <span>✔ In Stock</span>}
</div>
);
}
If we try to use product.description
, TypeScript immediately errors, because that field isn’t part of the fragment.
This keeps the component honest: its props are exactly the data it declared in its fragment.
The page query
On the page level, we can now reference the fragment in the query:
import { gql } from "@lib/graphql/codegen";
import { useQuery } from "@apollo/client";
import { ProductCard } from "@components/product-card";
const ProductsPageQuery = gql(`
query ProductsPage {
products {
...ProductCardFields
}
}
`);
Note: In some setups, you might need to manually interpolate fragments with ${ProductCardFragment}
. But when using a gql
wrapper generated by GraphQL Codegen (like @lib/graphql/codegen
), the tooling automatically injects fragment definitions. In that case, just writing ...ProductCardFields
is enough.
Rendering the product list
Finally, we can wire it up in the page component:
export function ProductListPage() {
const { data, loading, error } = useQuery(ProductsPageQuery);
if (loading) return <p>Loading...</p>;
if (error) return <p>Something went wrong!</p>;
return (
<div>
{data?.products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
);
}
Here’s the magic: the ProductCard
expects a ProductCardProduct
. The query guarantees that each p
is exactly that type, because it spreads the same fragment. The compiler enforces this contract end-to-end - from the GraphQL schema, through the query, down to the component’s props.
One important note is that using the fragment on the products
query is not even required, as long as you select at least the same fields that the fragment has. The types are enforced at the field level, not fragment level. So you could query products { id name url availability price }
and it would work the same.
If you forget to select any field on the query, TypeScript will complain, e.g. if you forgot availability
:
Type '{ id: string; name: string; url: string; price: number; }' is missing the following properties from type 'ProductCardFieldsFragment': availability.
Why colocated fragments matter
This pattern gives you very practical advantages:
1. Avoiding type mismatches in real queries
Most page queries don’t select all fields of an entity. If your component’s props are typed as the entire Product
, you’ll often end up with a type mismatch and resort to unsafe casts:
<ProductCard product={p as Product} /> // 👎 unsafe
In that case, if we forget to select a field in our query that the component needs, TypeScript won’t catch it and we’ll only know at runtime.
With the fragment approach, you never need to cast - the types line up automatically.
2. Better testing and Storybook ergonomics
Since your component only requires the fields in its fragment, you can build test objects or Storybook mocks with just those fields. No need to provide irrelevant Product
fields.
render(
<ProductCard
product={{
id: "1",
name: "Coffee Mug",
url: "/coffee",
availability: "IN_STOCK",
price: 12.95,
}}
/>
);
3. Clearer component contracts
The fragment documents the component’s data needs in the same place where the component lives. That makes the component easier to reason about and reuse.
With a handful of tools - Apollo Client, GraphQL Code Generator, and a tiny utility type - you can achieve end-to-end type safety. Each component declares its own data needs with a fragment, and the page queries compose those fragments.
This pattern keeps your codebase ergonomic, self-documenting, and safe from “type mismatch” headaches.