Type-Safe GraphQL on the Frontend

Part 6 of 7 in the “Production GraphQL with Netflix DGS” series — Bonus: The Client Side
The previous five articles covered the backend: schema design, data loaders, security, subscriptions, and scaling. But type safety doesn’t stop at the API boundary. This article covers how to carry that safety all the way to the Vue (or React) component — and how fragment design prevents the silent performance killer that is overfetching.
The Problem with String Queries
Most GraphQL tutorials start with something like this:
const response = await fetch('/graphql', {
method: 'POST',
body: JSON.stringify({
query: `
query {
product(id: "123") {
id
name
price
}
}
`
})
})
const data = await response.json()
// data.data.product.name — is this a string? An object? Who knows.
This works. It’s also a maintenance trap:
- No type safety — the response is
any. Typos in field names compile fine and fail silently at runtime. - No refactoring support — rename a schema field, and nothing breaks until a user reports it.
- No IDE help — no autocomplete, no inline documentation, no jump-to-definition.
- Hidden in strings — your bundler can’t tree-shake unused queries, and your linter can’t check them.
The fix is code generation: read the backend schema, generate TypeScript types and typed document nodes, and use those instead of strings.
Code Generation: Schema to TypeScript
The setup has three parts: a schema source, operation definitions, and a codegen tool that connects them.
1. Operations as .graphql Files
Instead of embedding queries in components, define them in standalone files:
# operations/product.graphql
query GetProductById($id: ID!) {
product(id: $id) {
...ProductFullFields
}
}
query GetProducts(
$searchText: String
$category: ProductCategory
$pageNumber: Int!
$pageSize: Int!
) {
products(
searchText: $searchText
category: $category
pageNumber: $pageNumber
pageSize: $pageSize
) {
items {
...ProductListItem
}
totalElements
totalPages
currentPage
}
}
mutation CreateProduct($input: CreateProductInput!) {
createProduct(input: $input) {
...ProductFullFields
}
}These files are the source of truth for what your frontend asks the backend.
2. Codegen Configuration
GraphQL Code Generator reads your backend schema and operation files, then generates TypeScript:
# codegen.yml
overwrite: true
schema:
- "../backend/src/main/resources/schema/*.graphqls"
documents:
- "./src/operations/*.graphql"
- "./src/fragments/*.graphql"
generates:
./src/generated/operations.ts:
plugins:
- typescript
- typescript-operations
- typed-document-node
config:
enumsAsConst: true
maybeValue: T
useTypeImports: true
exportFragmentSpreadSubTypes: trueKey settings:
enumsAsConst: true— generatesas constobjects instead of TypeScript enums. Better tree-shaking and no runtime overhead.maybeValue: T— usesTinstead ofT | null | undefinedwrappers. Cleaner types.useTypeImports: true— usesimport typefor type-only imports. Better tree-shaking.typed-document-node— generatesTypedDocumentNode<TData, TVariables>objects that carry their response and variable types.
3. What Gets Generated
After running graphql-codegen, you get:
// generated/operations.ts (auto-generated, never edit)
// Variable types — what the query accepts
export type GetProductByIdQueryVariables = {
id: string;
};
// Response types — what the query returns
export type GetProductByIdQuery = {
__typename?: 'Query';
product?: {
__typename?: 'Product';
id: string;
name: string;
price: number;
category: ProductCategory;
description?: string;
// ... all fields from ProductFullFields fragment
};
};
// The typed document — NOT a string, a typed object
export const GetProductByIdDocument: TypedDocumentNode<
GetProductByIdQuery,
GetProductByIdQueryVariables
> = { kind: "Document", definitions: [...] };
// Enums as const assertions
export const ProductCategory = {
Electronics: 'ELECTRONICS',
Clothing: 'CLOTHING',
Food: 'FOOD',
Books: 'BOOKS'
} as const;
export type ProductCategory = typeof ProductCategory[keyof typeof ProductCategory];The TypedDocumentNode is the key innovation. It’s a document object that carries its own types — your GraphQL client can infer both the variable type and the response type from it.
The GraphQL Composable
Rather than calling fetch() directly, a composable wraps the HTTP layer with auth, error handling, and observability:
// composables/useGraphQL.ts
export function useGraphQL<TData, TVariables>() {
const loading = ref(false)
const error = ref<Error | null>(null)
const data = ref<TData>()
async function query(
document: TypedDocumentNode<TData, TVariables>,
variables?: TVariables
): Promise<{ data: TData | null }> {
loading.value = true
error.value = null
try {
const response = await graphqlClient.post('', {
query: print(document),
variables
})
data.value = response.data?.data ?? null
return { data: data.value }
} catch (err) {
error.value = err as Error
throw err
} finally {
loading.value = false
}
}
async function mutate(
document: TypedDocumentNode<TData, TVariables>,
variables?: TVariables
): Promise<{ data: TData | null }> {
// Same implementation — mutations use POST just like queries
return query(document, variables)
}
return { loading, error, data, query, mutate }
}The magic is in the generic signature: useGraphQL<TData, TVariables>(). When you pass a TypedDocumentNode<GetProductByIdQuery, GetProductByIdQueryVariables>, TypeScript infers both types — variables and response — automatically.
A Static Client for Non-Component Contexts
The composable uses Vue refs and is designed for components. For stores, utilities, and non-component code, provide a static client:
export class GraphQLClient {
static async query<TData>(
document: TypedDocumentNode<TData, any>,
variables?: Record<string, any>
): Promise<TData | null> {
const response = await graphqlClient.post('', {
query: print(document),
variables
})
return response.data?.data ?? null
}
static async mutate<TData>(
document: TypedDocumentNode<TData, any>,
variables?: Record<string, any>
): Promise<TData | null> {
return this.query(document, variables)
}
}This gives stores and utility functions the same type safety without requiring a Vue component context.
Using Typed Operations in Components
Here’s how it all comes together in a Vue component:
<script setup lang="ts">
import { ref } from 'vue'
import { useGraphQL } from '@example/shared/composables'
import {
GetProductsDocument,
type GetProductsQuery,
type GetProductsQueryVariables
} from '@example/graphql-operations'
const products = ref<GetProductsQuery['products']>()
const loading = ref(false)
async function searchProducts(searchText: string) {
loading.value = true
const { query } = useGraphQL<GetProductsQuery, GetProductsQueryVariables>()
try {
const response = await query(GetProductsDocument, {
pageNumber: 0,
pageSize: 20,
searchText: searchText || undefined
})
if (response?.data?.products?.items) {
products.value = response.data.products
}
} catch (err) {
console.error('Search failed:', err)
} finally {
loading.value = false
}
}
</script>Notice what TypeScript gives you here:
- Variable validation — pass
{ pageNumber: "zero" }and the compiler rejects it. It must benumber. - Response typing —
response.data.products.itemsis typed asArray<{ id: string, name: string, price: number, ... }>. No casting, noas any. - Autocomplete — your IDE suggests
response.data.products.items[0].nameas you type. - Refactoring — rename
nametotitlein the schema, regenerate, and the compiler tells you every component that needs updating.
Fragments: The Key to Preventing Overfetching
Overfetching is GraphQL’s quiet performance problem. The client can request any field, so developers tend to request all fields. A dropdown that only needs id and name ends up fetching descriptions, timestamps, nested relationships, and counts — data that’s rendered into nothing.
The fix is tiered fragments: define what each UI context actually needs, and nothing more.
Fragment Hierarchy
For any entity that appears in multiple contexts, define a hierarchy from minimal to complete:
id, name
~80 B per item"] --> L["ProductListItem
+ price, category, rating, picture
~300 B per item"] L --> F["ProductFullFields
+ description, stockLevel, timestamps,
warehouse, reviews
~1.5 KB per item"] style D fill:#00bfa5,stroke:#00897b,color:#fff style L fill:#4a9eff,stroke:#2171c7,color:#fff style F fill:#7c4dff,stroke:#5e35b1,color:#fff
# fragments/product.graphql
# Level 1: Dropdowns and selects (2 fields)
fragment ProductDropdownItem on Product {
id
name
}
# Level 2: Cards and list rows (6 fields)
fragment ProductListItem on Product {
id
name
price
category
rating
picture
}
# Level 3: Detail views (all fields + relationships)
fragment ProductFullFields on Product {
id
name
price
category
rating
picture
description
stockLevel
createdAt
updatedAt
warehouse {
id
name
location
}
reviews {
id
text
rating
author {
id
name
}
}
}Each level adds exactly what’s needed for its context. A dropdown renders id and name. A product card adds price, category, rating, and an image. The detail page gets everything.
The Payload Difference
The numbers are significant:
ProductFullFields: ~1.5 KB per item × 50 items = 75 KB
ProductListItem: ~300 B per item × 50 items = 15 KB
ProductDropdownItem: ~80 B per item × 50 items = 4 KBFor a dropdown with 50 options, using the full fragment sends 18x more data than necessary. On mobile connections, that’s the difference between a snappy select input and a visible delay.
Dedicated Dropdown Queries
Take it a step further: create dedicated queries for dropdown components:
# operations/dropdown.graphql
query GetProductsForDropdown(
$searchText: String
$pageNumber: Int!
$pageSize: Int!
) {
products(
searchText: $searchText
pageNumber: $pageNumber
pageSize: $pageSize
) {
items {
...ProductDropdownItem
}
totalElements
}
}
query GetWarehousesForDropdown(
$pageNumber: Int!
$pageSize: Int!
) {
warehouses(pageNumber: $pageNumber, pageSize: $pageSize) {
items {
...WarehouseDropdownItem
}
totalElements
}
}These generate lean types:
type GetProductsForDropdownQuery = {
products?: {
totalElements?: number;
items?: Array<{
id: string;
name: string;
// That's it. Two fields.
}>;
};
};Fragment Composition
Fragments can compose other fragments to avoid repetition:
fragment ProductBasicFields on Product {
id
name
price
category
}
fragment ProductListItem on Product {
...ProductBasicFields
rating
picture
}
fragment ProductFullFields on Product {
...ProductBasicFields
rating
picture
description
stockLevel
createdAt
updatedAt
warehouse { ... }
reviews { ... }
}Change a basic field (say, adding currency alongside price) and every fragment that includes ProductBasicFields picks it up automatically. One change, one codegen run, all consumers updated.
Privacy-Aware Fragments
Some entities need different fragments based on authorization, not just UI context:
# Public context — no email, no private data
fragment PublicUserFields on PublicUser {
id
name
profilePicture
}
# Authenticated context — includes email
fragment UserBasicFields on User {
id
email
name
profilePicture
}
# Admin context — includes roles
fragment UserWithRoles on User {
...UserBasicFields
roles
authProvider
}This prevents accidentally exposing private fields in public-facing components. The fragment can’t overfetch because the fields simply aren’t in its definition.
Request Deduplication
In a component-based architecture (especially with micro-frontends), multiple components may mount simultaneously and fire identical queries. Without deduplication, the same data fetches twice:
Component A mounts → fetchProducts({ pageNumber: 0, pageSize: 20 })
Component B mounts → fetchProducts({ pageNumber: 0, pageSize: 20 }) // Duplicate!A simple deduplication layer in the GraphQL client prevents this:
const pendingRequests = new Map<string, Promise<any>>()
async function executeQuery(document: DocumentNode, variables?: any) {
const key = `${print(document)}-${JSON.stringify(variables || {})}`
// If an identical request is already in flight, reuse it
if (pendingRequests.has(key)) {
return pendingRequests.get(key)
}
const promise = graphqlClient.post('', { query: print(document), variables })
.finally(() => pendingRequests.delete(key))
pendingRequests.set(key, promise)
return promise
}Same query + same variables = same network request. The second caller gets the same promise and the same result, with zero additional network cost.
Observability: Client-Side Headers
Apollo Studio (and similar GraphQL observability tools) can identify which client sent each request — but only if the client sends identifying headers:
const graphqlClient = axios.create({
baseURL: apiUrl,
headers: {
'Content-Type': 'application/json',
'apollographql-client-name': 'my-web-app',
'apollographql-client-version': APP_VERSION
}
})These two headers — apollographql-client-name and apollographql-client-version — power the Clients dashboard in Apollo Studio:
- Which clients are sending requests (web app, mobile app, admin panel)
- Which version of each client is deployed
- Error rates per client version (did the latest deploy break something?)
- Field usage per client (which fields are actually consumed, and by whom?)
The version should be injected at build time — not hardcoded — so it tracks actual deployments:
// vite.config.ts
define: {
__APP_VERSION__: JSON.stringify(pkg.version)
}
// In the composable
declare const __APP_VERSION__: string
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined'
? __APP_VERSION__
: 'unknown'Without these headers, Apollo Studio shows your traffic as “Unidentified client” — which means you lose all per-client observability.
The Shared Operations Package
In a micro-frontend architecture, multiple apps consume the same GraphQL API. Rather than duplicating operations and types in each app, centralize them:
shared/
└── graphql-operations/
├── src/
│ ├── fragments/ # Fragment .graphql files
│ ├── operations/ # Query/mutation .graphql files
│ └── generated/
│ └── operations.ts # Auto-generated (do not edit)
├── codegen.yml
├── package.json
└── index.ts # Re-exports everythingEach micro-frontend declares the package as a workspace dependency:
{
"dependencies": {
"@example/graphql-operations": "workspace:*"
}
}Then imports typed operations:
import {
GetProductByIdDocument,
type GetProductByIdQuery,
type GetProductByIdQueryVariables
} from '@example/graphql-operations'Benefits:
- Single source of truth — fragments and operations are defined once, shared everywhere.
- Atomic updates — regenerate after a schema change, and every app gets the new types on the next build.
- No drift — impossible for two apps to have different definitions of the same query.
- Tree-shakeable — apps only bundle the operations they import.
The Development Workflow
Adding a new operation:
1. Write .graphql file in shared/graphql-operations/src/operations/
2. Run codegen: pnpm run codegen
3. Import the generated document in your component
4. TypeScript validates variables and response types immediatelyChanging the backend schema:
1. Backend team modifies schema.graphqls
2. Run codegen: pnpm run codegen
3. Compiler errors highlight every component affected
4. Fix the components, deploy with confidenceThe compiler becomes your migration guide. No grep, no manual auditing — just follow the red squiggles.
Summary: The Full Type Safety Chain
From schema to pixel, here’s how type safety flows across the entire stack:
.graphqls files"] -->|DGS codegen| BT["Backend Java Types
generated"] BT -->|"ACL: mapper"| D["Domain Model
business logic"] D -->|"ACL: mapper"| DB["Database Entities
persistence"] S -->|graphql-codegen| FT["Frontend TypeScript Types
generated"] FT -->|TypedDocumentNode| C["Vue / React Component
fully typed data"] S -.->|"single source of truth"| S style S fill:#ffd54f,stroke:#f9a825,color:#333 style BT fill:#7c4dff,stroke:#5e35b1,color:#fff style D fill:#00bfa5,stroke:#00897b,color:#fff style DB fill:#ff7043,stroke:#e64a19,color:#fff style FT fill:#7c4dff,stroke:#5e35b1,color:#fff style C fill:#4a9eff,stroke:#2171c7,color:#fff
Every link in this chain is validated at compile time. A field rename in the schema triggers compiler errors on both the backend (Java) and frontend (TypeScript). No runtime surprises, no silent breakage, no “it worked in dev.”
That’s the payoff of code generation: the schema is the contract, and the compiler enforces it on both sides.
Cover photo by Vishnu Kalanad on Unsplash.


