Plug-and-play GraphQL typesikkerhet ved bruk av gql.tada

Mye av appellen til GraphQL sammenlignet med f.eks. REST er at det muliggjør uthenting av data i potensielt komplekse strukturer på en uniform måte. Både data fra den etterspurte ressursen, samt ønskede data fra eventuelle relasjoner, kan enkelt hentes ut i én enkelt forespørsel. Samtidig er det klienten selv som definerer hvilke data som faktisk skal returneres, noe som kan redusere mengden nettverkstrafikk mellom klient og tjener.

I tillegg til dette er all data som tilgjengeliggjøres gjennom GraphQL-APIet automatisk beskrevet ved bruk av typer. Dette er en stor fordel kontra andre API-teknologier, og er et nyttig sikkerhetsnett for å luke bort potensielle bugs som oppstår når tjener eller klient mottar data den ikke forventer. Ola har tidligere skrevet om bruk av Protobuf for å muliggjøre typesikkerhet i heterogene nettverk. Men hva om man allerede har en eksisterende app som benytter seg av et GraphQL-API, og ikke ønsker å bruke mye tid og krefter på å skrive om på arkitekturen? Kan man allikevel dra nytte av fordelene typer gir med minimal innsats?

Har du en klient skrevet i TypeScript er svaret “ja!”, ved hjelp av et nyttig rammeverk som heter gql.tada. I denne artikkelen skal jeg vise hvordan vi tok dette i bruk på en eksisterende webapplikasjon, og hvilken nytteverdi det ga oss.

Oppsett

Selv om det faktiske APIet til GraphQL er typet, er det dessverre ikke noen automatikk i at data som hentes på klienten også blir det.

Det har lenge fantes verktøy som f.eks. GraphQL-Codegen som automatisk genererer typer basert på et eksisterende GraphQL-API. Min erfaring er at slike verktøy har mye påkrevd oppsett med pakker som må installeres og skript som må legges til, og er vrient å få til å fungere akkurat som man ønsker.

Rammeverket gql.tada fungerer på en helt annen måte, og er skrevet som en TypeScript-plugin (nytt i versjon 5) som automatisk henter typene fra GraphQL-APIet når TypeScript kompileres. Det gjør at du som utvikler får en “set it and forget it”-opplevelse, og personlig har jeg nærmest glemt at gql.tada er installert fordi det bare fungerer.

I tillegg velger du selv hvilke spørringer og mutasjoner som skal types automatisk eller ikke, noe som gjør det enklere å taes i bruk på et eksisterende prosjekt uten å måtte fikse tusenvis av feil først.

Vi tok i bruk verktøyet på frontend av Mist, en webapplikasjon skrevet i React som bruker @apollo/client til å kommunisere med et GraphQL-API eksponert fra en backend. Her er et (forenklet) eksempel på koden til en eksisterende spørring for å hente ut en paginert liste av brukere og vise dem i en tabell:


import { gql, useQuery } from '@apollo/client'
import { AllUsersQuery, AllUsersQueryInput } from 'modules/users/types'

const ALL_USERS_QUERY = gql`
  query AllUsers(
    $q: String
    $orderBy: String
  ) {
    allUsers(
      q: $q
      orderBy: $orderBy
    ) {
      edges {
        node {
          id
          username
          firstName
          lastName
          phone
        }
      }
    }
  }
`

const { data } = useQuery<AllUsersQuery, AllUsersQueryInput>(
    ALL_USERS_QUERY,
    {
      variables: {
        q: debouncedSearch,
        orderBy: sort
      },
    }
)
  
const tableData = data.allUsers.edges.map(({ node }) => node).map(user => {
  return {
    id: user.id,
    data: {
      username: user.username,
      email: user.email,
      firstName: user.firstName,
      lastName: user.lastName,
      phone: parseInt(user.phone)
    },
  }
})

return (
	<TableComponent data={tableData} />
)

Dette er kode som antagelig virker kjent for de fleste som har jobbet med GraphQL i React før. Derimot vil endringer i User-ressursen fra APIet ikke automatisk bli reflektert i koden. Det skaper utfordringer dersom man glemmer å vedlikeholde typene AllUsersQuery  og AllUsersQueryInput når det forekommer en endring.

Om man f.eks. fjerner firstName  og lastName for å introdusere et nytt felt name i stedet, vil ikke denne koden gi noen feilmeldinger på det, men alle GraphQL-spørringene vil begynne å feile fordi de etterspurte feltene ikke lenger eksisterer i schemaet. Og dersom phone plutselig blir returnert som en Int, vil parseInt-funksjonen feile, fordi den forventer et argument av typen String.

Resultater

La oss installere gql.tada og ta det i bruk for å automatisk følge typene fra APIet. Vi fulgte startguiden på den offisielle nettsiden, som i skrivende stund innebærer:

  1. Installer pakkene gql.tada og @0no-co/graphqlsp (TypeScript-plugin)
  2. Legg til TypeScript-pluginen under compilerOptions.plugins i din eksisterende tsconfig.json, og pek den mot GraphQL-API-adressen din.
  3. Det er ikke noe steg 3 😉

Deretter kan vi omskrive koden over for å kvitte oss helt med de statiske typene:


import { useQuery } from '@apollo/client'
import { graphql } from 'gql.tada';

const ALL_USERS_QUERY = graphql(`
  query AllUsers {
    ...
  }
`)

const { data } = useQuery(
    ALL_USERS_QUERY,
    { ... }
)
  
const tableData = data.allUsers.edges.map(({ node }) => node).map(user => {
  return {
    id: user.id,
    data: {
      username: user.username,
      email: user.email,
      firstName: user.firstName,   // TS2339: Property firstName does not exist on type
      lastName: user.lastName,     // TS2339: Property lastName does not exist on type
      phone: parseInt(user.phone)  // TS2345: Argument of type `number` is not assignable to parameter of type `string`
    },
  }
})

return (
	<TableComponent data={tableData} >
)

Nå blir begge problemene nevnt over plukket opp av TypeScript-kompilatoren før koden i det hele tatt bygges. Hvis firstName  og lastName er blitt fjernet, vil vi få opp en feil om at user.firstName og user.lastName ikke lenger finnes. Og tilsvarende med phone: Nå vil vi få en feilmelding om at funksjonen forventer en String, men at typen er Number.

I andre filer som benyttet seg av de statiske typene, kan i vi stedet bytte dem ut med de innebygde verktøytypene ResultOf<typeof ALL_USERS_QUERY> og VariablesOf<typeof ALL_USERS_QUERY>. For å enda mer oppfordre til gjenbruk kan man trekke ut de etterspurte feltene til et fragment, og deretter bruke dette til å definere hvordan User-typen ser ut.

Etter å ha skrudd på typing på flere og flere spørringer i den eksisterende Mist-koden, startet det å dukke opp feil som uheldigvis var blitt oversett tidligere. Dette var for det meste feil om at data potensielt kunne bli returnert som null eller undefined, men i ett tilfelle oppdaget vi en ulik implementasjon av et enum på frontend og backend – dette ville sannsynligvis ikke blitt oppdaget organisk av oss utviklere, så der reddet gql.tada oss fra en potensiell (sint) kundemail!

Fallgruver

Til slutt vil jeg nevne noen mindre positive erfaringer jeg har gjort meg etter å ha brukt gql.tada en stund.

Min største utfordring er at typesikkerheten kun fungerer på den uthentede dataen, og ikke mens man skriver de faktiske spørringene eller mutasjonene. Det vil si at all kode inne i graphql()-wrapperen må skrives som ren tekst, og alle de potensielle feil det innebærer. Med forbehold om at jeg har konfigurert noe feil, kan man argumentere for at dette går litt utenfor verktøyets opprinnelige misjon. Det finnes uansett egne GraphQL-plugins til de største IDEene som tar seg av denne oppgaven, for ikke å snakke om GraphiQL eller andre GraphQL-klienter. Men det hadde vært kjekt med alt på ett sted!

Benytter man seg av pipelines for å bygge og rulle ut kode, må det nevnes at man er nødt til å sjekke inn og inkludere den autogenererte env.d.ts typefilen for at koden skal bygges på server. Om man bruker et eksternt API, eller ikke selv utvikler backend, er det ofte at det dukker opp endringer i denne filen som ikke har noe med koden du skriver på akkurat nå, noe som kan forkludre pull requests. I tillegg er filstørrelsen relativt stor – i vårt tilfelle er den på over 1 MB.

Det er også verdt å lese gjennom dokumentasjonen, særlig om du benytter deg av fragments. Disse blir ikke automatisk typet, og man er nødt til å kalle funksjonen readFragment() for at typene fra fragmentet skal bli tatt i bruk. Og om du har definert egne skalarer i APIet, f.eks. en ID  eller Date, er du nødt til å overstyre standardkonfigurasjonen og importere graphql()-wrapperen derfra for at disse skal bli riktig typet.

Konklusjon

Ingen verktøy er perfekte, men alt i alt synes jeg nytteverdien gql.tada leverer gjør det verdt å prøve ut, særlig når innsatsen er såpass lav. Det er et spennende verktøy som allerede har hjulpet oss i utviklingen, og som vi kommer til å bruke videre på Mist og andre prosjekter i fremtiden.

Skrevet av
Christian De Frène

Andre artikler