/io

A library that turns impure code into pure and testable code.

Primary LanguageTypeScriptMIT LicenseMIT

Gitter Build Status codecov Dependency Status npm version

IO

Introduction

IO is a structure for expressing imperative computations in a pure way. In a nutshell it gives us the convenience of imperative programming while preserving some of the properties of a purely functional programming. Most notable code that uses IO can be tested in a purely declarative way without actually running side-effects.

Table of contents

Features

  • Provides a declarative and pure way to express code with side-effects
  • Has a nice API for easily testing IO code without running side-effects
  • Ships with both CommonJS and ES2015 modules for tree-shaking
  • Is written in TypeScript so comes with full comprehensive type definitions

Installation

IO can be installed from npm. The package ships with both CommonJS modules and ES6 modules

npm install @funkia/io

Tutorial

Impure functions

Let's say we have a function fireMissiles that takes a number n and then fires n missiles. If fewer than n missiles are available then only that amount of missiles is fired. The function returns the amount of missiles that was successfully fired.

function fireMissiles(amount: number): number { ... }

Certainly that is a very easy way of firing missiles. But unfortunately it is also impure. This, among other things, will make it tricky to test code using fireMissiles without actually firing missiles every time the tests are run.

IO turns impure functions into pure ones

To solve the issue IO provides a method called withEffects. It converts fireMissiles from an imperative procedure, that actually fires missiles, to a pure function that merely returns a description about how to fire missiles.

const fireMissilesIO = withEffects(fireMissiles);

fireMissilesIO has the type (amount: number) => IO<number>. Here IO<number> means an IO-action that does something and then produces a value of type number. The crucial difference about fireMissilesIO is that it has no side-effects and that it always return an equivalent IO-action when given the same number. It is pure.

At first this might seem like nothing but a neat trick. But it actually allows us to construct imperative computations in a functional way. To work with IO-actions we can use the fact that IO is a functor, an applicative and a monad. Thus we can for instance use it with go-notation.

const fireMissilesAndNotify = fgo(function*(amount) {
  const n = yield fireMissilesIO(amount);
  yield sendMessage(`${n} missiles successfully fired`);
  return n;
});

Here sendMessage has the type (msg: string) => IO<void>. It takes a string and returns an IO-action that sends the specified message.

Notice that the above code looks like imperative code. In a sense it is imperative code. It's a functional way of writing imperative code. Since sendMessage is pure it satisfies referential transparency. Instead of this:

go(function*() {
  yield sendMessage("foo");
  yield sendMessage("foo");
});

We can write this:

go(function*() {
  const sendFoo = sendMessage("foo");
  yield sendFoo;
  yield sendFoo;
});

If sendMessage had been impure this refactoring would not have worked–the side-effect in sendMessage would only have been carried out once. But since it's pure it's totally fine. In the dumb example above it only made a small difference but in a real program being able to perform such refactorings can be very beneficial.

Asynchronous operations

IO-actions can be asynchronous. This makes it possible to express asynchronous operations very conveniently. Instead of withEffects we can use withEffectsP to turn an impure function that returns a promise into a pure function.

const fetchIO = withEffectsP(fetch);

This creates a function with the return value IO<Response>. If the promise returned by the wrapped function rejects the IO-computation will result in an error. Error handling is described in the next section.

Error handling

The IO monad comes with error handling features. It works through the functions throwE and catchE. They resemble throw and catch but instead of being language-features they are built into the IO implementation.

A value of IO<A> can not only produce a value of type A. It may also produce an error.

To throw an error inside you use throwE:

const sendFriendlyMessageTo = fgo(function*(name, message) {
  if (message.indexOf(":)") === -1) {
    yield throwE("Please include a friendly smiley :)");
  }
  const exists = yield checkUserExistence(name);
  if (!exists) {
    yield throwE("User does not exist");
  }
  return yield sendMessageTo(name, message);
});

Once an error is yielded the rest of the computation isn't being run. The resulting IO value will produce an error instead of a value.

To catch an error you use catchE. As its first argument it takes a error function handling. As its second argument it takes an IO computation. It returns a new IO computation.

const sendFriendlyMessageWithUnfriendlyError(name, message) {
  return catchE(
    (error) => "Some error happened. I won't tell you which!",
    sendFriendlyMessageTo(name, message)
  );
}

Here is an example of using fetchIO with error handling. Since parsing the body from a fetch response as JSON is an asynchronous operation we define an additional function responseJson.

const responseJson = withEffectsP((response) => response.json());

const fetchUsersPet = fgo(function*(userId) {
  const response = yield catchE(
    (err) => throwE(`Request failed: ${err}`),
    fetchIO(usersUrl + "/" + userId)
  );
  if (response.states === 404) {
    yield throwE("User does not exist");
  }
  const body: User = yield responseJson(response);
  if (body.pet === undefined) {
    yield throwE("User has no pet");
  } else {
    return body.pet;
  }
});

Running and testing

An IO-action can be run with the function runIO. The function actually performs the operations in the IO-action and returns a promise that resolves when it is done or rejects is the IO produces and unhandled error. runIO is an impure function.

Besides running IO-actions we can also test them. Or "dry-run" them. To see how this works consider one of the previous examples with a small bug added in:

const fireMissilesAndNotify = fgo(function*(amount) {
  const n = yield fireMissilesIO(amount);
  yield sendMessage(`${amount} missiles successfully fired`);
  return n;
});

The error is that we don't send a message about how many missiles where actually fired. Instead we send the number of missiles that where requested to be fired. We can test the function with testIO:

it("fires missiles and sends message", () => {
  testIO(fireMissilesAndNotify(10), [
    [fireMissilesIO(10), 10],
    [sendMessage(`10 missiles successfully fired`), undefined]
  ], 10);
});

The first argument to testIO is the IO-action to test. The second is a list of pairs. The first element in each pair is an IO-action that the code should attempt to perform, the second element is the value that performing the action should return. The last argument is the expected result of the entire computation.

However, the test above doesn't uncover the bug. Let's write another one that does:

it("fires missiles and sends message", () => {
  testIO(fireMissilesAndNotify(10), [
    [fireMissilesIO(10), 5],
    [sendMessage(`5 missiles successfully fired`), undefined]
  ], 5);
});

Here we specify that when the code attempts to run fireMissilesIO(10) it should get back the response 5. After this the next line will throw because our implementation passes a string to sendMessage that mentions 10 instead of 5. Therefore testIO will throw and our test will fail.

API

IO.of(a: A): IO<A>

Converts any value into a IO that will return that value.

withEffects((...args) => A): IO<A>

Converts an impure function into an IO

withEffectsP(p: Promise<A>): IO<A>

Converts a Promise into an IO

throwE(error: any): IO<any>

Once an error is yielded the rest of the computation isn't being run. The resulting IO value will produce an error instead of a value.

catchE(errorHandler: (error: any) => IO<any>, io: IO<any>): IO<any>

As its first argument it takes a error function handling. As its second argument it takes an IO computation. It returns a new IO computation.

testIO<A>(e: IO<A>, arr: any[], a: A): void

The first argument to testIO is the IO-action to test. The second is a list of pairs. The first element in each pair is an IO-action that the code should attempt to perform, the second element is the value that performing the action should return. The last argument is the expected result of the entire computation.

Contributing

Contributions are very welcome. Development happens as follows:

Install dependencies.

npm install

Run tests.

npm test

Running the tests will generate an HTML coverage report in ./coverage/.

Continuously run the tests with

npm run test-watch

We also use tslint for ensuring a coherent code-style.