Skip to content

identinet/sanctuary-cheat-sheet

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

61 Commits
 
 
 
 
 
 

Repository files navigation

Work in Progress

Sanctuary Cheat Sheet

The goal of this cheat sheet is to make it easy for newcomers and experienced developers to work with the Sanctuary library by describing common patterns and best practices.

WARNING: the information in this cheat sheet is by no means a comprehensive collection of all the library functions and types. Nor are the examples the only or even the best way of how to use them in your code. Keep this in mind and also dive into other resources. I highly recommend reading Things I wish someone had explained about Functional Programming and the Fantas, Eel, and Specification.

Contents

  1. Read-Eval-Print-Loop - try out Sanctuary
    1. Web
    2. Local browser
    3. Deno
  2. Function definition
    1. Define parameters
    2. Define processing steps
    3. Define function signature with types
  3. Type definition - create your own functional types
  4. Piping - connecting function outputs to function inputs and avoid intermediate variables
  5. Print debugging - inspecting intermediate values
  6. Branching - handling if-else cases
  7. Promises - back to the Future
    1. Integration with Sanctuary
    2. Basic setup
    3. Promises - working with Promise-returning functions
    4. Processing - the Future is yet to come
    5. Parallel Futures
    6. Stopping the Future
  8. map or chain?
    1. map - transform a list of values
    2. chain - perform type-aware transformation of values
    3. join - combine multiple objects of the same type
  9. filter - remove unneeded values
  10. reduce - accumulate values
  11. Error handling
    1. Maybe - the better null/NaN/undefined return value
    2. Either - the better alternative to throw Error
    3. bimap - mapping over two values (potential failure)
  12. Pair - storing key-value pairs
  13. Libraries - little helpers
  14. Resources - additional things that might be helpful
    1. Sanctuary
    2. Fantasy Land Spec / Theory
    3. Video Tutorials
    4. Books
    5. Miscellaneous

Read-Eval-Print-Loop - try out Sanctuary

Web

A web-based Sanctuary-only REPL is available online, start typing in the green box.

Local browser

To quickly get a local Sanctuary and Fluture REPL, open the developer tools in your browser (keyboard shortcut Ctrl-Shift-i) and execute this instruction:

let S; let def; let F;
import("https://deno.land/x/[email protected]/repl.js").then(l => {S = l.S; def = l.def; F = l.F;});

Deno

To quickly get a local Sanctuary and Fluture REPL, run this command:

deno repl --eval 'import {S, def, F} from "https://deno.land/x/[email protected]/repl.js"'

Function definition

There are three aspects to defining functions:

  1. Define the parameters - one after the other
  2. Define the processing steps
  3. Define the function signature with types

Define parameters

In functional programming functions are usually curried. This means that a function only takes one parameter. If a function requires more than one parameter it should be defined as a function that takes one parameter and returns a functional that requires another parameter.

Fortunately, JavaScript's arrow functions make it really easy to create curried functions:

const myfunction = (parameter1) => (parameter2) => (parameter3) => {
  // the function body
};

Define processing steps

In sanctuary there's a convenient way of defining the processing steps - the pipe function. pipe takes a list of functions and it passes the output value of one function as the input value into the following function. See Piping for more information:

const myfunction = (parameter1) =>
  S.pipe([
    // first processing step
    doA,
    // second processing step
    doB,
    // ...
    doC,
  ])(parameter1);

Define function signature with types

For very simple functions defining processing steps might be enough. However, to get all the benefits from sanctuary's type checking functionality the function signature needs to be defined the sanctuary way. Take a look at the built-in types:

// define a def function that makes it easy to create functions with
type checks
const $ = require("sanctuary-def");
const def = $.create({
  checkTypes: process.env.NODE_ENV === "development",
  env,
});

//    add :: Number -> Number -> Number
const add =
def ('add')                           // name
    ({})                              // type-class constraints
    ([$.Number, $.Number, $.Number])  // input and output types
    (x => y => x + y);                // implementation

TODO daggy

Type definition - create your own functional types

The types that can be used by functions need to be first defined. Sanctuary has a number of constructors for defining types. Take a look at sanctuary's Type constructors. Here is a very simple one that defines an integer. Keep in mind that a documentation URL is required where more information can be found about the type - the project's REAMDE.md is a good place to keep the type definition documentation at:

const Integer = $.NullaryType(
  // name
  "Integer",
)(
  // documentation URL
  "http://example.com/my-package#Integer",
)(
  // supertypes
  [],
)(
  // predicate values need to satisfy
  (x) =>
    typeof x === "number" &&
    Math.floor(x) === x &&
    x >= Number.MIN_SAFE_INTEGER &&
    x <= Number.MAX_SAFE_INTEGER,
);

Piping - connecting function outputs to function inputs and avoid intermediate variables

Functions often contain a lot of calls to other functions. The intermediate values of the function calls are stored in variables are passed again to other function calls. It might look something like this:

const myfunction = (parameter1) => (parameter2) => (parameter3) => {
  const resA = doA(parameter1);
  const resB = doB(parameter2)(resA);
  const resC = doC(parameter3)(resB);
  return resC;
};

This could be optimized with the pipe function by removing the variables and feeding the intermediate results directly into the next function:

const myfunction = (parameter1) => (parameter2) => (parameter3) =>
  S.pipe([
    doA,
    // output of doA is piped as input into doB
    doB(parameter2),
    doC(parameter3),
  ])(parameter1);

Print debugging - inspecting intermediate values

The goal of print debugging is to peek into a function execution chain and learn about intermediate results.

Example, given the following function - how to inspect the return value of doA?

const myfunction = S.pipe([
  // some function calls
  doA,
  doB,
  doC,
]);

Solution, define a log function that prints a message and the received value and returns the value. Then add the log function between doA and doB:

const log = (msg) => (value) => {
  console.log(msg, value);
  return value;
};

const myfunction = S.pipe([
  doA,
  // insert log function
  log("Return value of do3:"),
  doB,
  doC,
]);

Branching - handling if-else cases

In a function there is often the need to handle two cases differently:

const myfunction = (parameter1) => {
  const res = computeSomething(parameter1);
  if (res > 0) {
    doA(res);
  } else {
    doB(res);
  }
  // further processing
};

In Sanctuary it could be done with the ifElse function as follows:

const myfunction = (parameter1) =>
  S.pipe([
    computeSomething,
    S.ifElse((res) => res > 0)(doA)(doB),
    // further processing
  ])(parameter1);

This could get ugly if there are more cases that need to be distinguished, e.g. res < 0, res < 10 and res >= 10:

const myfunction = (parameter1) =>
  S.pipe([
    computeSomething,
    S.ifElse((res) => res < 0)(doB)(S.ifElse((res) => res < 10)(doA))(doC),
  ])(parameter1);

In this case it might be easier to TODO ...?

Promises - back to the Future

Sanctuary doesn't provide special handling for Promises. However, since they're used all over the place in JavaScript it would be great to deal with them in a functional way. There's a functional Promises library for this: Fluture

Integration with Sanctuary

Here's the official Fluture sanctuary integration. The important lines are:

import sanctuary from "sanctuary";
import { env as flutureEnv } from "fluture-sanctuary-types";
const S = sanctuary.create({
  checkTypes: true,
  env: sanctuary.env.concat(flutureEnv),
});
import { attemptP, encaseP, fork, parallel } from "Fluture";

Basic setup

The fork call needs to be present in the program and there should be ideally only one fork call. fork processes the Promise. Without fork no processing of Futures takes place.

fork(
  // error case
  log("rejection"),
)(
  // resolution case
  log("resolution"),
)(attemptP(() => Promise.resolve(42)));

Promises - working with Promise-returning functions

There are two main helper functions by Fluture to deal with Promises: attemptP and encaseP.

attemptP takes a function that doesn't take a parameter and turns it into a Future, e.g.:

attemptP(() => Promise.resolve(42));

encaseP takes a function that takes one parameter and turns it into a Future, e.g.:

encaseP(fetch)("https://api.github.com/users/Avaq");

Processing - the Future is yet to come

The main question is how do we deal with Futures in pipe. There are two important cases to keep in mind: map or chain?. Either we process the Future with map (2) - in this case no knowledge about the Future is required by the function that receives the value - or with chain (3) - in this case the Future is consumed and a new future needs to be returned by the function.

If we forget to use map or chain in a function call (1), the function receives the unfinished Future. It's like acting on a Promise without calling .then() or await on it.

const myfunction = S.pipe([
  encaseP(fetch),
  log("Try to log the output of fetch:"), // 1
  S.map(log("Log the output of fetch:")), // 2
  S.map(extractURL),
  S.chain(encaseP(fetch)), // 3
]);

fork(log("rejection"))(log("resolution"))(
  myfunction("https://api.github.com/users/Avaq"),
);

Parallel Futures

It's also possible to process multiple Futures in a functional way. For example, multiple long-running computations should to be performed. parallel provides this functionality and controls the number of parallel executions with the first parameter:

const myLongRunningFunction = (x) => {
  // computation take 1 sec
  return new Promise((resolve, reject) => setTimeout(resolve, 1000, x * 2));
};

fork(log("rejection"))(log("resolution"))(
  S.pipe([
    // 5 Futures are created
    S.map(encaseP(myLongRunningFunction)),
    // 2 Futures are processed in parallel until all are resolved
    parallel(2),
  ])([1, 2, 3, 4, 5]),
);

Stopping the Future

Unlike Promises, Futures don't execute the contained function unless fork is called on it. This makes it possible to stop a Future or to never execute it if not needed. The functionality is described in detail in the Cancellation documentation.

map or chain?

There are these two different functions, map and chain, that look very similar. However, using one over the other is sometimes advantageous.

map - transform a list of values

map is defined by the Functor class type. Every Functor implements map. Functors are often arrays and map maps a function over every element of the array. Example, add 1 to every element in an array of numbers:

const numbers = [1, 2, 3];
const add = (number1) => (number2) => number1 + number2;
S.map(add(1))(numbers);

// result: [2, 3, 4]

In addition, something like a Pair or a Promise could also be a Functor. In this case map maps over the value, e.g. the result of a Promise or the value of a Pair.

const pair = S.Pair("a")(1);
const add = (number1) => (number2) => number1 + number2;
S.map(add(1))(pair);

// result: Pair ("a") (2)

As you can see in the example, the add doesn't concern itself with the inner workings of the data type but just operates on the value. map does the heavy lifting of getting the Functors value out and wrapping the modified value back in a Functor. This is very convenient because it makes functions easily applicable to all kinds of Functors.

chain - perform type-aware transformation of values

However, sometimes this is intelligence of putting the returned value back in a Functor works against us. For example, we want to parse an integer from string but only want to return a Just value if the integer is greater than 10 otherwise Nothing. If we tried to do this with map we'd end up with this result:

S.pipe([
  S.parseInt(10),
  S.map(S.ifElse((v) => v > 10)(S.Just)((v) => S.Nothing)),
])("100");

// result: Just (Just (100))

There are now two nested Just data types. As you can see from the implementation, the function that's called by mapalready uses the complex data type Pair (implemented by Just and Nothing). Therefore, if since we pass a Pair into the function and the function returns a Pair, we don't needmap's feature of wrapping the returned value in the passed in Functor. chain as defined by the Chain class type does exactly that, it expects the function to properly wrap the return value in the Functor. This is important when working with Promises to ensure that we're not wrapping an unresolved Promise inside a resolved Promise but return the unresolved Promise so we can wait for its completion:

S.pipe([
  S.parseInt(10),
  S.chain(S.ifElse((v) => v > 10)(S.Just)((v) => S.Nothing)),
])("100");

// result: Just (100)

join - combine multiple objects of the same type

If you receive a value that's wrapped twice in the same type we can use join to remove one layer of wrapping:

S.pipe([
  S.parseInt(10),
  S.map(S.ifElse((v) => v > 10)(S.Just)((v) => S.Nothing)),
  S.join, // added join
])("100");

// result: Just (100)

Note that the added join plays nicely in case Nothing is returned by parseInt:

S.pipe([
  S.parseInt(10),
  S.map(S.ifElse((v) => v > 10)(S.Just)((v) => S.Nothing)),
  S.join, // added join
])("invalid100");

// result: Nothing

filter - remove unneeded values

When composing function calls with pipe it's common that arrays of values are processed. map is great for transforming array elements with the help of other functions. However, sometimes the list of array elements needs to be reduced before processing them further. For example, null values or Nothing values need to be removed or numbers that are lower than a certain threshold. This can be easily done with filter that takes a predicate / filter function:

S.filter(
  // predicate function that's applied to input values
  (x) => x > 3,
)(
  // the input values
  [1, 2, 3, 4, 5],
);

// [ 4, 5 ]

reduce - accumulate values

In the same way as filter, reduce operates on an array of values and transforms + collects them into an accumulated/reduced new value. This concept of reducing values is so powerful that map and filter can be expressed with reduce. However, expressing map or filter via reduce is more difficult to read than using the predefined functions. Therefore, we'll stick to simple reduction feature here. For example, the values of an array could be summed up with reduce:

S.reduce(
  // function that performs the accumulation / reduction of values
  (acc) => (x) => acc + x,
)(
  // start value for acc
  0,
)(
  // the input values
  [1, 2, 3, 4, 5],
);

// result: 15

Error handling

When processing data sometimes the data doesn't conform to the requirements and an error is raised. In Sanctuary there are multiple ways of handling errors, a few of them are explored here:

Maybe - the better null/NaN/undefined return value

A function might not be able to operate on all possible input values. For example, the parseInt function takes a string and tries to parse an integer from it. When it fails to parse the string the function could return null, undefined or NaN but this leaves lots of room for interpretation as it's not clear whether the function was able to process the input properly.

Instead, a Maybe type could be returned that wraps the actual result in either a Just or a Nothing object. When wrapping the return value in a Maybe object further processing steps graciously deal with the result. For example, map only executes the transformation function when a Just object is returned:

const myParseInt = (str) => {
  const res = parseInt(str);
  if (isNaN(res)) {
    return S.Nothing;
  }
  return S.Just(res);
};

S.show(
  S.map(
    S.pipe([
      // call to function that produces a Maybe result object
      myParseInt,
      // further processing
      S.map((x) => x + 10),
    ]),
  )(["1", "invalid1"]),
);

// result: [Just (11), Nothing]

Additional functions exist for handling Maybe objects.

Either - the better alternative to throw Error

Another programming challenge is to deal with errors, for example when an attempted division by zero. Instead of throwing an Error, Sanctuary offers the Either type that can be a Right object that includes the successful result or a Left object that includes the error.

Either is different from Maybe in that Left contains additional data for processing and potentially recovering from the error while Nothing contains no data.

const myDiv = (num) => (divider) => {
  if (divider === 0) {
    return S.Left("Division by zero.");
  }
  return S.Right(num / divider);
};

S.show(
  S.map(
    S.pipe([
      // call to function that produces an Either result object
      myDiv(25),
      // further processing
      S.map((x) => x + 10),
    ]),
  )([5, 0]),
);

// result: [Right (15), Left ("Division by zero.")]

Additional functions exist for handling Either objects.

bimap - mapping over two values (potential failure)

When there are multiple subtypes to deal with like Left and Right it would be handy to be able to map over both options. bimap provides this feature so we can begin handling the failure:

const myDiv = (num) => (divider) => {
  if (divider === 0) {
    return S.Left("Division by zero.");
  }
  return S.Right(num / divider);
};

S.show(
  S.map(
    S.pipe([
      // call to function that produces an Either result object
      myDiv(25),
      // further processing
      S.bimap(S.toUpper)((x) => x + 10),
    ]),
  )([5, 0]),
);

// result: [Right (15), Left ("DIVISION BY ZERO.")]

mapLeft is another option for just interacting with the error case. For Futures, coalesce and mapRej are the respective functions for dealing with rejected values.

Pair - storing key-value pairs

Sanctuary provides the type Pair for storing key-value pairs. Compared to a simple JavaScript Object ({}), Pair plays nicely with other functions, e.g. map and mapLeft:

const p = S.Pair('balance')(1)

S.show(S.map(x => x * 2)(p))
// result: Pair ("balance") (2)

S.show(S.mapLeft(x => "accountnumber")(p))
// result: Pair ("accountnumber") (1)

Libraries - little helpers

Resources - additional things that might be helpful

Sanctuary

Fantasy Land Spec

Video Tutorials

Books

Miscellaneous

  • Awesome List Functional Programming in JavaScript: Awsome FP JS