Development
  // 5 min. read

Runtime JSON type-checking met Typescript interfaces

Dit artikel is technisch qua inhoud en voornamelijk gericht aan ontwikkelaars.

Bytecode werkt op dit moment aan een project voor een startup. We ontwikkelen een app die gekoppeld is aan een backend service voor dataopslag. Voor dit project had ik de wens om meer veiligheid te hebben rondom de API calls, om tijdens runtime de types te valideren.

Achtergrond

Bij Bytecode maken we intensief gebruik van Typescript voor front-end en mobiele ontwikkeling, om te voorkomen dat het liberale dynamische typesysteem van Javascript fouten zal veroorzaken. Typescript voegt een extra laag aan zekerheid toe. In de afgelopen jaren hebben we een sterke afname van type-gerelateerde fouten gezien door het gebruik van Typescript.

Toch heeft Typescript ook limitaties. De voornaamste hiervan is dat types en interfaces bij compile-time verdwijnen. Er zijn dus geen run-time checks op externe data die niet beschikbaar is bij compilatie, zoals bijvoorbeeld API responses. Idealiter zou Typescript, net als Go, gebruik maken van marshalling, waarin types gecheckt worden. Helaas verdwijnt echter de Typescript-laag gedurende compile-time en blijft alleen Javascript over: code die niets weet van de types en interfaces die in de source-code gedefineerd zijn. Dat is dus jammer genoeg niet mogelijk.

Voorheen deden wij onze API calls direct binnen Redux actions, geen probleem voor kleine applicaties. Echter is dit, mede gezien het single-responsibility principe, niet de beste opzet en naarmate projecten groeien worden de Redux actions erg onoverzichtelijk. Hierom zijn we recentelijk overgegaan naar het maken van losse API-packages als een abstractielaag bovenop de API calls. We roepen nu alleen een functie aan die alle calls uitvoert en zoekt naar HTTP fouten. Als er geen fouten zijn, krijgen we de data terug. Als er wel fouten zijn, wordt er een error gethrowd. De Redux-code weet dus verder niets van de details van de API call.

Een andere reden om gebruik te maken van een losse API-package, is onze wens om later gemakkelijker een SDK op te kunnen zetten zodra we een ander platform gaan targetten (denk aan een webapplicatie waar ook een mobiele app bij komt). Het is dan mogelijk om gebruik te maken van gedeelde code, zonder duplicate logica. Omdat deze API-packages dus een belangrijke rol innemen, worden de run-time garanties ook belangrijker. Als de SDK zegt dat een functie een bepaald returntype teruggeeft, willen we dit ook garanderen of anders een foutmelding geven.

Eisen en onderzoek

Onze onderzoeksvraag bestond uit meerdere delen:

  • Hoe kunnen we op een generieke manier een JSON object checken tegen een Typescript interface, zonder dat er duplicate code nodig is voor type definities of in het gebruik?
  • Hoe is het bovenstaande te realiseren, zonder dat andere productiecode buiten de API-package aangepast hoeft te worden om dit checken mogelijk te maken?
  • Op welke manier is dit mogelijk te maken in zowel NodeJS, als React Native, als in de browser?

Er zijn voldoende libraries die het mogelijk maken om een JSON structuur te checken, op basis van een DSL (domain specific language). Dit was echter niet waar we naar op zoek waren, omdat we al Typescript gebruikten en niet dezelfde definitie op twee manieren bij willen houden. Het liefst zouden we een oplossing bouwen waarbij er geen code generation of extra stap in compilation nodig is, maar alles on-the-fly gedurende runtime (zoals Go dat doet).

Een paar maanden terug las ik een blogpost van Picnic over hun project "Aegis", waarin ze een oplossing voor dit probleem hadden aangeboden. Echter merkte ik dat het nog moeilijk te implementeren was. De code is open-source, maar er was geen voorbeeld van implementatie op grotere schaal, omdat dit in de proprietary app van Picnic is. Ook was er dan een code-generation stap nodig. Dit zouden we, als mogelijk, liever niet hebben.

Op de Subreddit van Typescript had ik een post geplaatst, waarin ik mijn vraag stelde. Ik kreeg voornamelijk reacties met voorbeelden over oplossingen met code generation. Enkele reacties bespraken runtime oplossingen, maar deze oplossingen waren onnodig complex en/of vereisten aanpassingen binnen de build-configuratie van Typescript. Dit ontwijken we liever, omdat we iets experimenteels liever los houden van de rest van onze productiecode, zodat als we niet tevreden zijn, de veranderingen terug kunnen draaien.

Een mogelijke oplossing die door mijn hoofd schoot, was het volgende:

  • Laad alle type-definitions op via het filesystems als strings
  • Gebruik de Typescript compiler als productiedependency en parse deze strings
  • Vergelijk het resultaat van het parsen tegen de JSON data om te kijken of het aan de interfaces voldoet

Dit zou echter betekenen dat een substantieel deel van de Typescript-compiler onderdeel moet worden van de app en dus van een grotere bundle. De Typescript compiler is ook niet de snelste ter wereld, dus dit zou dan relatief veel extra tijd kosten wanneer het on-the-fly moet gebeuren. Hiernaast is het filesystem alleen maar geschikt voor Node.js en niet voor omgevingen in de browser, waardoor de compatibility niet behouden zou kunnen worden. Dit zou dus helaas niet gaan lukken.

Uiteindelijk heb ik ervoor gekozen om gebruik te gaan maken van Picnic's Aegis, vooral vanwege de simpliciteit van de tool en omdat het gebruikt kan worden zonder andere aspecten van het project (compilation stappen, configuraties of productiecode in andere delen van de applicatie) aan te passen.

Implementatie

De uiteindelijke implemenetatie is als volgt. Binnen de API map worden alle publieke types (dus de arguments en return types van de gehele API-package) gedefinieerd in de types map. Voor alle types in deze map worden door Aegis decoders gemaakt en opgeslagen in de internal map van de API-package.

Om bovenstaande oplossing voor Bytecode bruikbaar te maken, moesten er in Aegis een paar aanpassingen gemaakt worden. Zo hebben we bijvoorbeeld ESLint comments bovenaan het bestand toegevoegd. Dit is gedaan in een fork van Aegis op Github. Dit is de dependency die gebruikt wordt in het project van Bytecode. Voor het builden van de decoders is een commando toegevoegd aan de package.json van het React-Native/Expo project. Door simpelweg yarn run aegis te runnen wordt Aegis aangeroepen met de juiste arguments en worden alle decoders gebuild.

In de productiecode van de API-package werd al gebruikgemaakt van een internal returnOrThrow-functie, die de een intern API response type ontving (bestaande uit de response van de API en/of een error wanneer deze zich voor heeft gedaan), een error gooit wanneer deze er was en anders de data returnt. Deze functie is nu aangepast, zodat een tweede argument wordt meegegeven aan de functie, namelijk de decoder. In returnOrThrow wordt dan de decoder gebruikt om de data te checken, voordat deze wordt gereturnt. Zie het voorbeeld hieronder:

import { Decoder } from "decoders/types";
import { guard } from "decoders";

interface APIResultSuccess<T> { data: T; error?: undefined; }
interface APIResultFailure { data?: undefined; error: string; }
type APIResult<T> = APIResultSuccess<T> | APIResultFailure;

const throwOrReturn = <T>(result: APIResult<T>, decoder: Decoder<T>): T => {
    if (result.error) {
        throw new Error(result.error);
    }
    // We can assume that data is valid (type T) if no error was found
    const data = result.data as T;

    const decodeChecker = guard(decoder);
    const _ = decodeChecker(data); // Throws if it's not valid
    return data;
};

export default throwOrReturn;

Indien de return body niet voldoet aan de decoder, wordt er nu een error gegooid, die bij het aanroepen van de API call dus gecatcht kan worden.

Wishlist

Voor nu is de implementatie van JSON typechecks nog experimenteel gebruik. Binnen de codebase heeft deze typechecker slechts invloed op een klein (los) deel, dus het is later gemakkelijk weg te halen. Hierom is het geheel ook niet niet geautomatiseerd, iets wat we later als eerste zouden willen toevoegen. Omdat dit automatiseren zonder het aanpassen van de buildconfiguratie nog wel een uitdaging kan zijn, is de eerste stap een check toevoegen in de CI-pipeline die een foutmelding geeft als het runnen van Aegis file changes geeft (en dus herkend wordt dat de tool niet gerund is na aanpassingen van de types).

Een andere hele goede use-case van deze opzet zou zijn het end-to-end testen van de API waarvoor de package gebouwd is. Het end-to-end testen van APIs is iets wat al een tijdje op de wishlist van Bytecode staat. JSON type checking kan hierbij ook een grote meerwaarde leveren, zodat ook gelijk wordt gecheckt of de API data in de verwachte structuur teruggeeft.

Wat betreft verbeteringen van Aegis zelf, momenteel is Aegis vooral gebouwd voor de "happy flow", er zijn nog was edge-cases die niet helemaal lekker werken. De tool is nu zeker bruikbaar, maar er zijn nog wel verbeteringen nodig voor grootschalig gebruik.

Ondersteuning voor Aegis configuratiebestanden is ook een goede toevoeging. Nu moeten arguments voor importPath en outputFile als CLI-opties gegeven worden. Simpelweg aegis generate aanroepen, die dan de configuatie zelf oplaadt zou een mooie toevoeging zijn.

Als blijkt dat deze workflow erg fijn werkt voor Bytecode, is de kans groot dat we de Aegis tool zelf open-source verder zullen ontwikkelen. Voor nu is de tool ook nog niet beschikbaar op NPM. Dat zou de eerste stap zijn richting een stable release.

Voorbeeldproject

Als uitbreiding op dit artikel is er ook een voorbeeldproject aanwezig, met een simpele API call, waar de tool in actie te zien is.

Bekijk het project op Github.

Dit artikel is met passie geschreven door Bytecode, een jonge en moderne web agency. Wij laten jou graag zien hoe je de kracht van het internet kunt gebruiken om alles uit jezelf te halen. Dit doen we door bijvoorbeeld dit artikel te schrijven, maar ook door deze inzichten in onze werkzaamheden te verwerken.

Wil je meer over ons weten of kunnen we misschien iets voor je betekenen? Neem gerust contact met ons op of kom een keer langs op de koffie!

Schrijf je in voor onze nieuwsbrief!

Maandelijks brengen wij een interessante en leerzame nieuwsbrief uit met onder andere onze ‘Bytecast’, nieuwe artikelen of e-books, en natuurlijk updates over ons team en werkzaamheden.