Til hovedinnhold
Utvikler som sitter i en sofa forran et vindu med god utsikt

Funksjonell programmering i Enonic XP #2 - TypeScript

Publisert: 13. desember 2019 av Tom Arild Jakobsen

Vi anbefaler på det sterkeste å bruke TypeScript på serveren når du jobber med funksjonell programmering i XP. Typeskjekking av en kompilator vil fange opp mange feil før de havner i produksjon.

Artikkelnummer Andre artikler i denne serien
1 Funksjonell programmering i Enonic XP #1 - Intro
2 Funksjonell programmering i Enonic XP #2 - TypeScript
3 Funksjonell programmering i Enonic XP #3 - Option
4 Funksjonell programmering i Enonic XP #4 - Either
5 Funksjonell programmering i Enonic XP #5 - IO
6 Funksjonell programmering i Enonic XP #6 - Eksempler

Hvordan kompilere TypeScript i Enonic

  1. Bruk webpack-starter for å sette opp et Enonic-prosjekt. TypeScript-støtte er inkludert (anbefalt).
  2. Alternativt kan man installere TypeScript fra npm og bruke tsc for å kompilere TS til JS. 
    npm install -g typescript
    tsc my-file.ts

Hva er TypeScript?

TypeScript er JavaScript pluss statiske typer. Man kompilerer TypeScript til JavaScript, slik at vi kan kjøre det backend i Enonic XP, eller i nettleseren.

Variabler med typer

For å uttrykke at en variabel har en type kan man gjøre følgende:

// Slik gir vi variablene typer:
let myNumber: number = 8;
let myString: string = "hei"; 

// TypeScript-kompilatoren vil ikke tillate dette, siden vi prøver å sette en 'number'-variabel til en 'string':
myNumber = myString;

Interfaces

For å begrense formen på et objekt i TypeScript definerer vi et interface. Det er mye tryggere og enklere å programmere mot et objekt, hvis du kan være helt sikker på formen på objektet.

// Alle objekter av typen Employee, må følge denne formen
interface Employee {
  id: number;
  name: string;
  email?: string; // email har "?" som gjør at 'undefined' er en lovlig verdi
}

const employee1: Employee = {
  id: 007,
  name: "James Bond",
  email: "jbond@universal-exports.com"
};

// TypeScript-kompilatoren vil ikke tillate dette siden 'id' og 'name' er obligatoriske felter
const employee2: Employee = {};

Fra Content Type til Interface

En Content Type i Enonic beskriver formen på json-data som lagres. Og det er jo akkurat det Interfaces også gjør.

Derfor har vi laget en open source Gradle-plugin som brukes som leser content type-xmler, og genererer Typescript-interfaces.

Eksempel:

Gitt at vi har en content type beskrevet i content-types/article/article.xml:

<?xml version="1.0" encoding="UTF-8"?>
<content-type>
  <display-name>Article</display-name>
  <super-type>base:structured</super-type>
  <form>
    <input name="title" type="TextLine">
      <label>Title of the article</label>
      <occurrences minimum="1" maximum="1"/>
    </input>
    <input name="body" type="HtmlArea">
      <label>Main text body</label>
      <occurrences minimum="0" maximum="1"/>
    </input>
  </form>
</content-type>

Så vil det genereres en fil (i samme mappe) som heter content-types/article/article.ts:

export interface Article {
  /** 
   * Title of the article 
   */
  title: string;

  /**
   * Main text body
   */
  body?: string;
}

Dette TypeScript-interfacet kan nå brukes serverside i Enonic. Dette gir oss en sikkerhet på at koden vår alltid er kompatibel med content som den får fra Enonic. Skrivefeil vil rett og slett ikke kompilere, og man vil ikke klare å bygge prosjekter før feilen har blitt rettet.

Det er også veldig deilig at den gir oss code completion i IDEer, noe som gjør at vi kan kode raskere og vite at koden er korrekt.

Og hvis man kjører genereringa av TypeScript hver gang man bygger i Enonic, så tvinges TypeScript-koden til å bli oppdatert til å følge oppdateringer i Content typen, ellers vil bygging feile.

Eksempel på bruk av interface i service/article/article.ts:

import {Request, Response} from 'enonic-types/controller';
import {Content, ContentLibrary} from 'enonic-types/content';
import {Article} from '../../site/content-types/article/article'

const contentLib: ContentLibrary = __non_webpack_require__("/lib/xp/content");

export function get(req: Request): Response {
  // Resultatet er av type "Content", og content.data er av type "Article"
  const content: Content<Article> = contentLib.get({ key: req.params.key! })!;

  return {
    status: 200,
    body: {
      id: content._id,
      title: content.data.title,
      text: content.data.text // Denne linja vil feile kompilatoren, siden "Article" fra forrige eksempel ikke har feltet "text"
    }
  }
}

Det regnes som dårlig praksis å bruke ! for å fjerne nullability. Vi gjør her for å holde disse eksemplene så enkle som mulig.

Fra site, page og part til interface

Det er også mulig å generere interfaces for sites, pages, parts, layouts og id-providers.

Prinsippene er det samme som for content types, så vi blir ikke å gå mer i detalj på disse i denne artikkelen. Men det viktige er at vi får den samme tryggheten og tette koblingen mellom xml og TypeScript for all data i Enonic som baserer seg på xml-konfigurasjon.

Typer for Enonic biblioteker

Vi har laget et bibliotek med TypeScript definisjoner for Enonics standardbiblioteker som heter enonic-types. Følgende standard biblioteker er støttet:

Funksjonell programmering med fp-ts

Siden funksjonell programmering i stor grad handler om å ta output fra en funksjon, og bruke det som input i neste funksjon (funksjonskomposisjon), er det ekstra viktig å ha kontroll på typene. Dette gjelder spesielt når vi begynner å bruke mer komplekse typer fra fp-ts.

Vi skal her se på noen funksjoner som går igjen i mange typer. Vi finner map, chain og fold, i for eksepel Array, Option og Either.

Vi skal her se på hvordan vi bruker dem med Array, mens påfølgende artikler vil ta for seg de andre typene. Ja, de samme metodene (eller tilsvarende) finnes i vanilla-JS/vanilla-TS, så bruk gjerne de vanlige i den daglige programmeringa. Her bruker vi fp-ts-funksjonene kun for å demonstrere det konseptuelle.

map

Vi bruker map for å kjøre en funksjon på alle elementene i et array og returnere resultatet.

import { map } from 'fp-ts/lib/Array';

const arr: Array<string> = ["1", "2", "3"];

const result: Array<number> = map(parseInt)(arr);
// [1, 2, 3]

Vi kan se at map fra fp-ts tar parameterene på en litt annen måte enn vi er vant med fra Array.prototype.map. I fp-ts tar map én parameter – som er funksjonen som skal kjøres – og returnerer en ny funksjon som tar én parameter, og det er selve arrayet.

Dette er en teknikk som heter currying, og hvis dette er ukjent for deg anbefaler jeg at du leser Professor Frisby's Mostly Adequate Guide to Functional Programming kapittel 4.

chain

En chain er veldig lik en map. Et annet navn på chain er flatMap (se Array.prototype.flatMap). Vi bruker chain, når callback funksjonen også retunerer et Array, og vi ønsker å flate ut (flatten) de to nivåene av Arrays, så det bare blir ett nivå. Husk at chain bare flater ut på to nivåer, selv om du har enda flere nivåer med nested Array.

import { chain, map } from 'fp-ts/lib/Array';

const arr: Array<string> = ["Functional Programming", "Enonic XP", "in TypeScript"];

const searchTerms: Array<string> = chain(str => str.split(" "))(arr);
// ["Functional", "Programming", "Enonic", "XP", "in", "TypeScript"] 

// USING THE SAME FUNCTION IN "map" 
const fromNormalMap: Array<Array<string>> = map(str => str.split(" "))(arr); 
// [["Functional", "Programming"], ["Enonic", "XP"], ["in", "TypeScript"]]

For andre typer som Option<A> eller Either<E, A> ønsker man alltid bare ha ett nivå dybde (altså ikke noe av type Option<Option<string>>), derfor er chain veldig nyttig for å slå sammen flere nivåer, når callback funksjonen returnere et resultat av samme type.

fold

Vi bruker fold for å pakke ut verdien som er inni en type. En nært beslektet funksjon er Array.prototype.reduce, med den forskjellen at reduce tar en initsiell verdi, og fold ikke gjøre det.

For Array kan man bestemme hvilken vei man ønsker å iterere, for å generere et resultat, så det eksisterer foldLeft og foldRight. I eksempelet under ser vi på foldLeft.

foldLeft gir oss en mulighet til å bruke rekursjon til å iterere gjennom hele arrayet, og foldLeft tar to argumenter.

  1. onNil brukes til å terminere rekursjonen når man sender inn et tomt Array og tar en funksjon med 0 argumenter.
  2. onCons tar en funksjon med 2 argumenter, hvor head er det første elementet i arrayet og tail er resten av arrayet (som kan brukes rekursivt med samme funksjon).

import { foldLeft } from 'fp-ts/lib/Array'; // For array har vi "foldLeft" og "foldRight" i stedet for bare "fold"

const arr: Array<number> = [1, 2, 3];
Сode
const getSum = foldLeft(
  () => 0,
  (head, tail) => head + getSum(tail) // 1 + 2 + 3 + 0
);

const sum = getSum(arr); 
// sum = 6

For andre typer enn Array, bruker vi gjerne fold som siste steg av det vi gjør, for å generere det vi skal returnere. Hvis det er i en controller vi er, brukes gjerne fold til å gjøre en IOEither om til en Response.

Pipe

Vi kan bruke pipe til å sette opp en kjede, der output fra en funksjon blir tatt som input i neste funksjon.

Eksempel:

import {pipe} from "fp-ts/lib/pipeable";

const result = pipe( // 1
  1, // 2
  (num) => num + 5, // 3
  (num) => num * 2,
); 
// result = 12

  1.  pipe er en funksjon som tar en verdi som sitt første argument, og alle påfølgende argumenter er funksjoner som kun tar ett argument.
  2. Den første parameteren er verdien 1.
    (Det er vanlig å liste ut parameterene til pipe nedover i stedet for inline).
  3. Alle påfølgende parametere til pipe er nå funksjoner som som tar resultatet fra linja over som input.

 

Pipe med fp-ts

pipe blir fort veldig nyttig når man ønsker å gjøre en rekke operasjoner fra fp-ts på rekke.

Eksempel:

import { pipe } from "fp-ts/lib/pipeable";
import { map, foldLeft } from "fp-ts/lib/Array";

// getSum is a function(input: Array<number): number;
const getSum: (input: Array<number>) => number // 1
  = foldLeft(
    () => 0,
    (head, tail) => head + getSum(tail)
  );

function increment(num: number) {
  return num + 1;
}

const sum: number = pipe(
  [1, 2, 3], // 2
  map(increment), // 3
  getSum // 4
);

// sum = 9

  1. Samme getSum som i eksempelet over
  2. Verdien som er første parameter i pipe er her av type Array<number>
  3. Vi bruker map fra fp-ts/lib/Array til å kalle increment funksjonen på hver verdi i arrayet, og så returnere resultatene som et Array<number>.
  4. En pipe vil ofte avsluttes med en fold, som her skjer inni den rekursive getSum funksjonen. For en Enonic XP controller vil siste fold inni en pipe ofte returnere en Response, som videre returneres av controlleren.

Veien videre

I neste kapittel skal vi gå dypere inn i fp-ts, og se på null-håndtering med Option-typen.