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:
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:
# 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:
# 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: trueKlíčová nastavení:
enumsAsConst: true— generujeas constobjekty místo TypeScript enumů. Lepší tree-shaking a žádná runtime režie.maybeValue: T— používáTmísto wrapperůT | null | undefined. Čistší typy.useTypeImports: true— používáimport typepro importy, které obsahují pouze typy. Lepší tree-shaking.typed-document-node— generuje objektyTypedDocumentNode<TData, TVariables>, které nesou své typy odpovědi a proměnných.
3. Co se vygeneruje
Po spuštění graphql-codegen dostanete:
// 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:
// 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:
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ě:
<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ýtnumber. - Typování odpovědi —
response.data.products.itemsje typováno jakoArray<{ id: string, name: string, price: number, ... }>. Žádné castování, žádnéas any. - Autocomplete — IDE vám navrhuje
response.data.products.items[0].nameběhem psaní. - Refaktoring — přejmenujte
namenatitleve 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í:
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
}
}
}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á:
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 KBU 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:
# 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:
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í:
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:
# 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:
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í:
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:
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í:
// 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:
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 everythingKaždý micro-frontend deklaruje balíček jako workspace závislost:
{
"dependencies": {
"@example/graphql-operations": "workspace:*"
}
}A poté importuje typované operace:
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:
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 immediatelyZměna schématu na backendu:
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 confidenceKompilá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:
.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.


