TypeScript-raadsel #1: Taarten bakken

Op dit blog ga ik regelmatig een raadsel plaatsen waarmee je je TypeScript-kennis kunt aanscherpen. Ik begin simpel, maar het wordt steeds ingewikkelder. Dus mocht je onderstaande allemaal al weten, kom dan zeker nog eens terug voor moeilijkere raadsels.

Je beste vriend Joep bakt graag taarten. Helaas kent hij maar drie recepten. Vrienden en familie bestellen steeds vaker taarten bij hem. Om het voor zichzelf wat gemakkelijker te maken heeft hij een programmaatje geschreven om automatisch zijn boodschappenlijstje te genereren. Zijn taarten zijn echter beter dan zijn programmeerskills.

De ingredienten voor zijn taarten heeft hij vastgelegd in het volgende object.

const boodschappenPerTaartsoort = {
  appeltaart: ingredientenVoorAppeltaart,
  slagroomtaart: ingredientenVoorSlagroomtaart,
  kwarktaart: ingredientenVoorKwarktaart
};

function voegToeAanBestelling(
        taartnaam: string, aantal: number) {
  // code die de ingredienten aan de 
  // boodschappenlijst toevoegt
}

Hij heeft een functie voegToeAanBestelling geschreven waarmee ingredienten automatisch aan zijn boodschappenlijst worden toegevoegd. Helaas is dat niet helemaal fool proof. Laatst bestelde een grapjas een frikandellenvlaai. Daarvan heeft Joep het recept helemaal niet, en zijn boodschappenlijstje crashte.

Opdracht

De taartnaam-parameter kan nu willekeurige strings bevatten. Pas de typering van de parameter zo aan, dat alleen taartnamen die bekend zijn in de boodschappenPerTaartsoort door TypeScript geaccepteerd worden. Als Joep in de toekomst nieuwe taartnamen aan de boodschappenPerTaartsoort toevoegt, moeten die ook aan de bestelling kunnen worden toegevoegd, zonder dat je de typering zelf handmatig nog hoeft te wijzigen. Een type dat een hardgecodeerde lijst van drie taartnamen accepteert is een leuk begin, maar geen goede oplossing.

Als je de opdracht lastig vindt, kun je de onderstaande stappen gebruiken om tot een eindoplossing te komen.

Stappen

  1. Definieer een type dat alleen de waarden ‘appeltaart’, ‘slagroomtaart’ en ‘kwarktaart’ accepteert, ongeacht welke taartnamen er in boodschappenPerTaartsoort worden geaccepteerd.
  2. Definieer expliciet een interface voor het boodschappenPerTaartsoort-object. Geef deze expliciet de properties appeltaart, slagroomtaart en kwarktaart, allen van het type any. Gebruik de sleutels van die interface om het  type vast te leggen voor je taartnaam.
  3. Verwijder de interface die je net expliciet hebt vastgelegd. Leid de interface in plaats daarvan impliciet af van het boodschappenPerTaartsoort-object. Gebruik dit en de bovenstaande twee stappen om je type vast te leggen.

Als een stap niet lukt, kun je gebruik maken van de onderstaande hints.

Hint 1: Maak gebruik van een string literal type.

Hint 2: Gebruik het keyof keyword.

Hint 3: Gebruik het typeof keyword.

Oplossing

Stap 1

Je kunt in TypeScript vastleggen welke waarde een object precies mag bevatten. Zo kun je een variabele taart maken, die alleen de inhoud ‘lekker’ kan bevatten. Dat is natuurlijk niet zo nuttig. Maar combineer je dit met een zogenaamd union type, dan kun je ook meerdere waarden toestaan dan alleen ‘lekker’.

Een union type is een combinatie van meerdere types. Iedere variabele die aan één van de types voldoet, voldoet automatisch ook aan de union type. Zo kun je een variabele ingrediënten maken, die één string, of een array van strings kan bevatten. Of je kunt een type maken dat exact één van de waarden ‘appeltaart’, ‘slagroomtaart’ of ‘kwarktaart’ kan bevatten. Behalve dat TypeScript deze types bij het compileren checkt, helpen ze je IDE ook om automatisch de waarden van variabelen automatisch te completeren.

Een union type van strings noemen we ook wel een string literal type.

const taart: 'lekker' = 'lekker';

type Ingredienten = string | string[];
const ingredientenVoorAppeltaart: Ingredienten = 
        'appeltaart';
const ingredientenVoorKwarktaart: Ingredienten = 
        ['kwark', 'taart'];

type Taartsoort = 'appeltaart' | 
        'slagroomtaart' | 'kwarktaart';
const favoriet: Taartsoort = 'kwarktaart';

Stap 2

Als we een interface hebben, kunnen we de sleutels van die interface opvragen met behulp van het keyof keyword. keyof geeft een string literal type terug, die precies de waarden kan bevatten die properties zijn van de interface.

In dit geval definiëren we een interface met de properties appeltaart, slagroomtaart en kwarktaart. Als we keyof op deze interface toepassen, krijgen een type dat alleen de waarden ‘appeltaart’, ‘slagroomtaart’ en ‘kwarktaart’ kan bevatten.

interface BoodschappenPerTaartsoort {
  appeltaart: any,
  slagroomtaart: any,
  kwarktaart: any
}

type Taartsoort = keyof BoodschappenPerTaartsoort;

const favoriet: Taartsoort = 'kwarktaart';

Stap 3

We hebben nu expliciet een interface BoodschappenPerTaartsoort (met een hoofdletter B) vastgelegd. Door expliciet te maken dat de constante boodschappenPerTaartSoort (met een kleine b) van het type BoodschappenPerTaartsoort is, kunnen we afdwingen dat de functie voegToeAanBestelling van Joep alleen met ‘appeltaart’, ‘slagroomtaart’ of ‘kwarktaart’ kan worden aangeroepen.

In de toekomst wil Joep nog meer taartsoorten gaan bakken. Iedere keer als hij een taartsoort toevoegt, moet hij zowel de variable boodschappenPerTaartsoort, als het type BoodschappenPerTaartsoort uitbreiden. Dat is twee keer hetzelfde en dat vindt hij irritant.

Impliciet ligt het type van boodschappenPerTaartsoort natuurlijk al gewoon vast. TypeScript doet aan type inference, en dat betekent dat ieder type dat niet expliciet is vastgelegd, automatisch van TypeScript het meest geschikte type krijgt. We kunnen dat automatisch afgeleide type ook gebruiken. Dat doen we door gebruik te maken van het typeof keyword. Als je typeof toepast op een variabele, krijg je het type van die variabele terug. typeof boodschappenPerTaartsoort komt dus precies overeen met ons type BoodschappenPerTaartsoort.

type Taartsoort = 
        keyof typeof boodschappenPerTaartsoort;
const favoriet : Taartsoort = 'kwarktaart';

function voegToeAanBestelling(
        taartnaam: Taartsoort, aantal: number) {
  // code die de ingredienten aan de 
  // boodschappenlijst toevoegt
}