Christian Gill

Control flow

From imperative to declarative.

pipes

Imperative

Control flow based on statements (if, for, while).

export const keepEvenInRange = (str: string): number | null => {
  const n = parseInt(str, 10)
  if (Number.isNaN(n)) {
    return null
  }
  if (0 >= n && n <= 100) {
    return null
  }
  if (n % 2 !== 0) {
    return null
  }
  return n
}

Declarative

Control flow based on expressions, data estructures, and composition.

Option (or Maybe) data type

Encodes the notion of, well, optional values.

Functions that operate with Optional values (eg. map or chain) provide control flow by only transforming the data contained in an Option when it's there, but allowing to build these pipes where we work as if the data was there instead of imperatively checking for each operation.

import * as O from 'fp-ts/lib/Option'
import {pipe} from 'fp-ts/lib/pipeable'

// type Option<Data>      =      None | Some<Data>
// type Either<Err, Data> = Left<Err> | Right<Data>

const parse = (str: string) => {
  const n = parseInt(str, 10)

  return Number.isNaN(n) ? O.none : O.some(n)
}

const isEven = (n: number) =>
  n % 2 === 0 ? O.some(n) : O.none

const isInRange = (from: number, to: number) => (n: number) =>
  from <= n && n <= to ? O.some(n) : O.none

export const keepEvenInRange = (str: string) =>
  pipe(
    str,
    parse,
    O.chain(isEven),
    O.chain(isInRange(0, 100)),
  )

Either data type

Either, is similarly Option, but instead of encoding the notion of optional values, it encodes the the notion of operations that might fail. Either we have a value assosiated with success (by convention) on the Right or one assosiated with failure on the Left.

Control flow works just like Option. Functions that operate on either (eg. map & chain) only transform data for the Right case, and the same pipe can be built if there were no errors.

import * as E from 'fp-ts/lib/Either'
import {pipe} from 'fp-ts/lib/pipeable'

// type Option<Data>      =      None | Some<Data>
// type Either<Err, Data> = Left<Err> | Right<Data>

const parseNum = (str: string) => {
  const n = parseInt(str, 10)

  return Number.isNaN(n) ? E.left('not a number') : E.right(n)
}

const isEven = (n: number) =>
  n % 2 === 0 ? E.right(n) : E.left('odd number')

const isInRange = (from: number, to: number) => (n: number) =>
  from <= n && n <= to ? E.right(n) : E.left('not in range')

export const keepEvenInRange = (str: string) =>
  pipe(
    str, 
    parseNum, 
    E.chain(isEven), 
    E.chain(isInRange(0, 100)),
  )

Semantics change, code doesn't

One of the benefits of data estructures oriented control flow is that by changing the data estructure we can change the semantics of the code without having to change how the code looks.

And this is possible becasue Option & Either implement the same type classes (Functor, Monad, Applicate, et al).

In the example, by switching from Option to Either we now have an error on the Left. Which makes it possible to know what went wrong instead of only knowing that something went wrong.

-import * as O from 'fp-ts/lib/Option'
+import * as E from 'fp-ts/lib/Either'
 import {pipe} from 'fp-ts/lib/pipeable'

 // type Option<Data>      =      None | Some<Data>
 // type Either<Err, Data> = Left<Err> | Right<Data>

 const parse = (str: string) => {
   const n = parseInt(str, 10)

-  return Number.isNaN(n) ? O.none : O.some(n)
+  return Number.isNaN(n) ? E.left('not a number') : E.right(n)
 }

 const isEven = (n: number) =>
-  n % 2 === 0 ? O.some(n) : O.none
+  n % 2 === 0 ? E.right(n) : E.left('odd number')

 const isInRange = (from: number, to: number) => (n: number) =>
-  from <= n && n <= to ? O.some(n) : O.none
+  from <= n && n <= to ? E.right(n) : E.left('not in range')

 export const keepEvenInRange = (str: string) =>
   pipe(
     str,
     parse,
-    O.chain(isEven),
-    O.chain(isInRange(0, 100)),
+    E.chain(isEven),
+    E.chain(isInRange(0, 100)),
 )

almost 4 years ago

Christian Gill