Control flow
From imperative to declarative.
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 Option
al 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)),
)