Products Consulting About Blog Contact Us Česky
arrow_back Back to blog

Type-Safe GraphQL on the Frontend

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:

TYPESCRIPT
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:

GRAPHQL
# 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:

YAML
# 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: true

Key settings:

  • enumsAsConst: true — generates as const objects instead of TypeScript enums. Better tree-shaking and no runtime overhead.
  • maybeValue: T — uses T instead of T | null | undefined wrappers. Cleaner types.
  • useTypeImports: true — uses import type for type-only imports. Better tree-shaking.
  • typed-document-node — generates TypedDocumentNode<TData, TVariables> objects that carry their response and variable types.

3. What Gets Generated

After running graphql-codegen, you get:

TYPESCRIPT
// 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:

TYPESCRIPT
// 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:

TYPESCRIPT
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:

VUE
<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 be number.
  • Response typingresponse.data.products.items is typed as Array<{ id: string, name: string, price: number, ... }>. No casting, no as any.
  • Autocomplete — your IDE suggests response.data.products.items[0].name as you type.
  • Refactoring — rename name to title in 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:

graph TD D["ProductDropdownItem
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
GRAPHQL
# 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:

Text
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 KB

For 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:

GRAPHQL
# 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:

TYPESCRIPT
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:

GRAPHQL
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:

GRAPHQL
# 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:

Text
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:

TYPESCRIPT
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:

TYPESCRIPT
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:

TYPESCRIPT
// 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:

Text
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 everything

Each micro-frontend declares the package as a workspace dependency:

JSON
{
    "dependencies": {
        "@example/graphql-operations": "workspace:*"
    }
}

Then imports typed operations:

TYPESCRIPT
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:

Text
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 immediately

Changing the backend schema:

Text
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 confidence

The 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:

graph TD S["Backend Schema
.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.

More from the Blog