Produkty Poradenství O nás Blog Kontakt English
arrow_back Zpět na blog

Typově bezpečné GraphQL na frontendu

Typově bezpečné GraphQL na frontendu

Šestý díl ze sedmi v sérii “Production GraphQL with Netflix DGS” — Bonus: Klientská strana


Předchozích pět článků pokrývalo backend: návrh schématu, data loadery, zabezpečení, subscriptions a škálování. Typová bezpečnost ale nekončí na hranici API. Tento článek ukazuje, jak ji přenést až do Vue (nebo React) komponenty — a jak návrh fragmentů brání tichému zabijákovi výkonu, kterým je overfetching.

Problém se string dotazy

Většina GraphQL tutoriálů začíná nějak takto:

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 — je to string? Objekt? Kdo ví.

Tohle funguje. Je to ale také past na údržbu:

  • Žádná typová bezpečnost — odpověď je any. Překlepy v názvech polí se zkompilují bez problémů a tiše selžou za běhu.
  • Žádná podpora refaktoringu — přejmenujte pole ve schématu a nic se nerozbije, dokud to nenahlásí uživatel.
  • Žádná pomoc od IDE — žádné autocomplete, žádná inline dokumentace, žádný jump-to-definition.
  • Skryté ve stringech — bundler nemůže provést tree-shaking nepoužívaných dotazů a linter je nemůže zkontrolovat.

Řešením je generování kódu: načíst schéma z backendu, vygenerovat TypeScript typy a typed document nodes a používat je místo stringů.

Generování kódu: Ze schématu do TypeScriptu

Setup má tři části: zdroj schématu, definice operací a codegen nástroj, který je propojí.

1. Operace jako .graphql soubory

Místo vkládání dotazů do komponent je definujte v samostatných souborech:

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
    }
}

Tyto soubory jsou jediným zdrojem pravdy o tom, co váš frontend požaduje od backendu.

2. Konfigurace codegenu

GraphQL Code Generator načte vaše schéma z backendu a soubory s operacemi, a poté vygeneruje 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

Klíčová nastavení:

  • enumsAsConst: true — generuje as const objekty místo TypeScript enumů. Lepší tree-shaking a žádná runtime režie.
  • maybeValue: T — používá T místo wrapperů T | null | undefined. Čistší typy.
  • useTypeImports: true — používá import type pro importy, které obsahují pouze typy. Lepší tree-shaking.
  • typed-document-node — generuje objekty TypedDocumentNode<TData, TVariables>, které nesou své typy odpovědi a proměnných.

3. Co se vygeneruje

Po spuštění graphql-codegen dostanete:

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];

TypedDocumentNode je klíčová inovace. Je to objekt dokumentu, který nese vlastní typy — váš GraphQL klient z něj dokáže odvodit jak typ proměnných, tak typ odpovědi.

GraphQL Composable

Místo přímého volání fetch() composable obalí HTTP vrstvu autentizací, zpracováním chyb a observabilitou:

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 }
}

Kouzlo je v generické signatuře: useGraphQL<TData, TVariables>(). Když předáte TypedDocumentNode<GetProductByIdQuery, GetProductByIdQueryVariables>, TypeScript automaticky odvodí oba typy — proměnné i odpověď.

Statický klient pro kontexty mimo komponenty

Composable používá Vue refs a je navržený pro komponenty. Pro stores, utility funkce a kód mimo komponenty poskytněte statický klient:

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)
    }
}

Tím získají stores a utility funkce stejnou typovou bezpečnost bez nutnosti Vue component kontextu.

Použití typovaných operací v komponentách

Takto to celé funguje dohromady ve Vue komponentě:

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>

Všimněte si, co vám TypeScript poskytuje:

  • Validace proměnných — předejte { pageNumber: "zero" } a kompilátor to odmítne. Musí to být number.
  • Typování odpovědiresponse.data.products.items je typováno jako Array<{ id: string, name: string, price: number, ... }>. Žádné castování, žádné as any.
  • Autocomplete — IDE vám navrhuje response.data.products.items[0].name během psaní.
  • Refaktoring — přejmenujte name na title ve schématu, přegenerujte a kompilátor vám řekne, které komponenty je třeba aktualizovat.

Fragmenty: Klíč k prevenci overfetchingu

Overfetching je tichý výkonnostní problém GraphQL. Klient může požadovat jakékoli pole, takže vývojáři mají tendenci požadovat všechna pole. Dropdown, který potřebuje jen id a name, nakonec stahuje popisy, časová razítka, vnořené vztahy a počty — data, která se nikam nevykreslí.

Řešením jsou víceúrovňové fragmenty: definovat, co každý UI kontext skutečně potřebuje, a nic víc.

Hierarchie fragmentů

Pro jakoukoli entitu, která se zobrazuje ve více kontextech, definujte hierarchii od minimální po kompletní:

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
        }
    }
}

Každá úroveň přidává přesně to, co je pro její kontext potřeba. Dropdown vykreslí id a name. Produktová karta přidá price, category, rating a obrázek. Detailní stránka dostane vše.

Rozdíl v objemu dat

Čísla jsou významná:

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

U dropdownu s 50 položkami odešle plný fragment 18x více dat než je nutné. Na mobilním připojení je to rozdíl mezi svižným select inputem a viditelným zpožděním.

Dedikované dropdown dotazy

Jděte o krok dál: vytvořte dedikované dotazy pro dropdown komponenty:

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
    }
}

Z nich se vygenerují úsporné typy:

TYPESCRIPT
type GetProductsForDropdownQuery = {
    products?: {
        totalElements?: number;
        items?: Array<{
            id: string;
            name: string;
            // That's it. Two fields.
        }>;
    };
};

Kompozice fragmentů

Fragmenty mohou skládat jiné fragmenty, aby se předešlo opakování:

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 { ... }
}

Změňte základní pole (třeba přidejte currency vedle price) a každý fragment, který zahrnuje ProductBasicFields, to automaticky převezme. Jedna změna, jedno spuštění codegenu, všichni konzumenti aktualizováni.

Fragmenty respektující soukromí

Některé entity potřebují různé fragmenty na základě autorizace, nejen UI kontextu:

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
}

Tím se zabrání náhodnému vystavení soukromých polí ve veřejně přístupných komponentách. Fragment nemůže provést overfetch, protože pole prostě nejsou v jeho definici.

Deduplikace requestů

V komponentové architektuře (zejména u micro-frontendů) se může stát, že se několik komponent připojí současně a spustí identické dotazy. Bez deduplikace se stejná data stáhnou dvakrát:

Text
Component A mounts → fetchProducts({ pageNumber: 0, pageSize: 20 })
Component B mounts → fetchProducts({ pageNumber: 0, pageSize: 20 })  // Duplicate!

Jednoduchá deduplikační vrstva v GraphQL klientovi tomu zabrání:

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
}

Stejný dotaz + stejné proměnné = stejný síťový request. Druhý volající dostane stejný promise a stejný výsledek, bez jakýchkoli dalších síťových nákladů.

Observabilita: Hlavičky na straně klienta

Apollo Studio (a podobné nástroje pro observabilitu GraphQL) dokáží identifikovat, který klient poslal daný request — ale pouze pokud klient odesílá identifikační hlavičky:

TYPESCRIPT
const graphqlClient = axios.create({
    baseURL: apiUrl,
    headers: {
        'Content-Type': 'application/json',
        'apollographql-client-name': 'my-web-app',
        'apollographql-client-version': APP_VERSION
    }
})

Tyto dvě hlavičky — apollographql-client-name a apollographql-client-version — pohánějí Clients dashboard v Apollo Studiu:

  • Kteří klienti posílají requesty (webová aplikace, mobilní aplikace, administrátorský panel)
  • Která verze každého klienta je nasazená
  • Chybovost podle verze klienta (rozbil poslední deploy něco?)
  • Využití polí podle klienta (která pole jsou skutečně konzumována a kým?)

Verze by měla být injektována v build time — ne hardcodovaná — aby sledovala reálná nasazení:

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'

Bez těchto hlaviček Apollo Studio zobrazí váš provoz jako “Unidentified client” — což znamená, že přijdete o veškerou per-client observabilitu.

Sdílený balíček operací

V micro-frontend architektuře více aplikací konzumuje stejné GraphQL API. Místo duplikace operací a typů v každé aplikaci je centralizujte:

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

Každý micro-frontend deklaruje balíček jako workspace závislost:

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

A poté importuje typované operace:

TYPESCRIPT
import {
    GetProductByIdDocument,
    type GetProductByIdQuery,
    type GetProductByIdQueryVariables
} from '@example/graphql-operations'

Výhody:

  • Jediný zdroj pravdy — fragmenty a operace jsou definovány jednou a sdíleny všude.
  • Atomické aktualizace — přegenerujte po změně schématu a každá aplikace dostane nové typy při dalším buildu.
  • Žádný drift — je nemožné, aby dvě aplikace měly různé definice stejného dotazu.
  • Tree-shakeable — aplikace zabalí pouze operace, které importují.

Vývojový workflow

Přidání nové operace:

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

Změna schématu na backendu:

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

Kompilátor se stane vaším průvodcem migrací. Žádný grep, žádný manuální audit — stačí následovat červené vlnovky.

Shrnutí: Kompletní řetězec typové bezpečnosti

Od schématu po pixel — takto proudí typová bezpečnost napříč celým stackem:

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

Každý článek tohoto řetězce je validován v compile time. Přejmenování pole ve schématu vyvolá chyby kompilátoru jak na backendu (Java), tak na frontendu (TypeScript). Žádná runtime překvapení, žádné tiché rozbití, žádné “ve vývoji to fungovalo.”

To je výsledek generování kódu: schéma je kontrakt a kompilátor ho vynucuje na obou stranách.


Titulní foto: Vishnu Kalanad na Unsplash.

Další z blogu