TypeScript-raadsel #2: Frikandellenvlaaien en andere taarten

Op dit blog plaats ik regelmatig een raadsel  waarmee je je TypeScript-kennis kunt aanscherpen. Eerder al was er het raadsel “Taarten bakken“. Daarop is dit een vervolg.

Je vriend Joep heeft een succesvolle handel opgezet in appeltaarten, kwarktaarten en slagroomtaarten. Maar hij krijgt steeds meer verzoeken voor frikandellenvlaaien en andere taarten waarvan hij het recept niet kent. Joep houdt wel van een uitdaging, en heeft besloten ook taarten te bakken die hij nog niet kent, als je hem het recept maar aanlevert.

Het aanleveren van recepten heeft hij mooi gestroomlijnd. Hij heeft een mooie single page application geschreven, waarin je een recept volgens de volgende interface kunt aanleveren:

interface Recept {
  taartnaam: string;
  bereidingstijd: number;
  ingredienten: string[];
  bereidingsstappen: string[];
  ikbengeenrobot: boolean;
}

Helaas levert echter nog niet iedereen even zinnige recepten aan. Laatst was er iemand die een recept leverde zonder ingrediënten, iemand anders die een taart leverde zonder naam, en er was zelfs een robot die een taartrecept had ingevuld!

Om dit soort onzinrecepten tegen te gaan, heeft hij validatiefuncties geschreven voor recepten.

interface ReceptValidator {
  taartnaam: (t: string) => boolean;
  bereidingstijd: (b: number) => boolean;
  ingredienten: (i: string[]) => boolean;
  bereidingsstappen: (b: string[]) => boolean;
  ikbengeenrobot: (i: boolean) => boolean; 
}

De ReceptValidator bevat voor iedere eigenschap van een recept een validatorfunctie, die een waarde voor de eigenschap als input krijgt, en een boolean teruggeeft die weergeeft of de property valide is. Joep heeft het idee dat hij de ReceptValidator-interface op een generiekere manier kan opschrijven. Hij definieert nu de eigenschappen nog eens een keer expliciet, terwijl hij die eigenlijk gewoon wil overnemen uit het Recept-object. En hij vindt het ook stom dat hij iedere keer opnieuw moet herhalen dat een validatorfunctie een boolean teruggeeft. Kun jij hem helpen?

Opdracht

Schrijf een generieke interface Validator<T>. Validator<T> moet dezelfde properties hebben als T. Elke property bevat een functie van het oorspronkelijke type naar boolean. Dus in het voorbeeld hierboven krijgt Validator<Recept> een property taartnaam, bereidingstijd, etc. De property taartnaam bevat een functie van string naar boolean. De property bereidingstijd bevat een functie van number naar boolean. Enzovoorts.

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

Stappen

We maken eerst een type Validatieresultaat<T>, dat per eigenschap van T een boolean teruggeeft of de validatorfunctie geslaagd is of niet.

  1. Definieer om te beginnen eerst een type dat willekeurige properties kan bevatten, maar waarvoor wel vastligt dat elke property van het type boolean is.
  2. Maak het type nu strikter. Definieer een type Validatieresultaat<T> dat precies dezelfde properties heeft als het type T. Echter is nu elke property van het type boolean.
  3. Schrijf het type uit stap 2 om naar een Validator<T>, waarvan de properties geen booleans bevatten, maar functies van het oorspronkelijke property-type naar boolean.

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

Hint 1: Maak gebruik van een index type.

Hint 2: Gebruik een mapped type met het keyof keyword. Definieer je type met behulp van het type keyword.

Hint 3: Gebruik T[K] in je typedefinitief om het type van je oorspronkelijke property aan te duiden.

Oplossing

Stap 1

Een simpel JavaScript-object bestaat uit property-namen en property-values. Een propertynaam is altijd een string. Een property-value kan van alles zijn. Eén manier om een property-value uit een object te halen, is met behulp van vierkante haken: object[‘propertynaam’]. De andere manier waarop blokhaken gebruikt kunnen worden is om een item uit een array te halen array[index]. In TypeScript kunnen we beide soorten typen aanduiden met een index type:

interface ArrayVanVanAlles {
  [property: number] : any
}

interface Validatieresultaat {
  [property: string] : boolean
}

const resultaat: Validatieresultaat = {
  taartnaam: true,
  geserveerdOpEenGoudenBordje: true,
  bereidingstijd: 'onbekend' // dit kan niet, 
          // want 'onbekend' is geen boolean
};

Bij ArrayVanVanAlles is number het type van de index en kun je dus tussen de blokhaken alleen getallen gebruiken. Wat je uit de ArrayVanVanAlles terugkrijgt is van het type any, en kan dus van alles zijn.

Bij Validatieresultaat is string het type van de index en kun je dus tussen de blokhaken willekeurige strings gebruiken. Bij Validatieresultaat hebben we expliciet vastgelegd dat alle property-values van het type boolean zijn. De variabele resultaat is van het type Validatieresultaat, met één fout. ‘onbekend’ is geen geldige property-value, want geen boolean.

We kunnen zowel voor beide bovenstaande typen ook het type keyword gebruiken in plaats van het interface keyword. In het algemeen wordt de voorkeur gegeven aan het interface-keyword.

Stap 2

In de vorige opdracht heb je gezien dat je gebruik kunt maken van het keyof keyword. keyof Recept geeft het volgende string literal type terug: ’taartnaam’ | ‘bereidingstijd’ | ‘ingredienten’ | ‘bereidingsstappen’ | ‘ikbengeenrobot’. Het zou mooi zijn als we [property: string] konden vervangen door [property: keyof T]. TypeScript staat in een interface-definitie echter alleen string en number toe als index type.

Maken we echter gebruik van het type-keyword, dan is er wel een geldige syntax om dit voor elkaar te krijgen:

type Validatieresultaat&lt;T&gt; = {
  [K in keyof T] : boolean
}

const resultaat: Validatieresultaat&lt;Recept&gt; = {
  taartnaam: true,
  geserveerdOpEenGoudenBordje: true // dit kan niet, 
                 // want geserveerdOpEenGoudenBordje 
                 // is geen property van Recept
};

Stap 3

Het is nu nog maar een kleine stap van een Validatieresultaat<T> naar een Validator<T>. We moeten het type boolean vervangen door een functietype, zoiets als: (value: any) => boolean. We willen de any echter nog iets strikter maken. Dit moet namelijk gelijk zijn aan het type van de oorspronkelijke property. Die noteren we in een typedefinitie in TypeScript als T[K]. De volledige oplossing wordt daarmee:

type Validator&lt;T&gt; = {
  [K in keyof T] : (value : T[K]) =&gt; boolean
}

const receptvalidator: Validator&lt;Recept&gt; = {
  taartnaam: value =&gt; !!value,
  bereidingstijd: value =&gt; value &gt; 0
  // plus ALLE andere properties van Recept 
  // -&gt; ALLE properties zijn verplicht
};

Merk op dat we in receptvalidator ALLE properties van het oorspronkelijke Recept-type moeten overnemen, terwijl we misschien slechts voor een deel van de oorspronkelijke properties een validatiefunctie willen schrijven. Hoe je dat doet zie je in de volgende opdracht.