04 - Use chain for composable error handling with nested Eithers¶
Here’s the video lesson.
This example involves parsing and returning a port number from a JSON config file.
REMEMBER: You can always read the full source code for these examples in the Gitlab repository.
v1 - An Initial, Traditional Implementation¶
This first implementation uses very standard, traditional style of JavaScript programming for the given situation.
/// <reference path="./typedefs.js" />
import { readFileSync } from 'fs';
import {
log,
} from '../lib/index.js';
/**
* Gets the port from the config file.
*
* Returns the port from the config file or 3000 if impossible to get
* it from config file.
*
* @param {string} configPath The path the config file.
* @return {number}
*
* @example
* getPort(); // No path, or incorrect path.
* // → 3000
*
* But if we have a proper config file, with a proper path param, then
* we get back that port.
*
* $ cat ./configs/config.json
* {
* "port": 8888
* }
*
* @example
* getPort('./configs/config.json');
* // → 8888
*/
const getPort = (configPath) => {
try {
const str = readFileSync(configPath);
const config = JSON.parse(str);
return config.port;
} catch (err) {
return 3000;
}
}
log(getPort());
// → 3000
log(getPort('./vid04/wrong-path.json'));
// → 3000
log(getPort('./vid04/config-04a.json'));
// → 8888
Everything looks fine, right‽
v2 - Capture Try Catch With Either¶
At this point, we attempt to capture the try/catch, either/or
condition with our Either
type.
The tests:
import { Left, Right } from './Either.js';
import { tryCatch } from './tryCatch.js';
describe('when we get an exception', () => {
it('should return a Left container', () => {
expect(
String(tryCatch(() => undefined.split('-')))
).toEqual(
// This hard-coded TypeError string bothers me. What other
// approach could be used?
String(
Left(
"TypeError: Cannot read property 'split' of undefined"
)
)
);
});
});
describe('when we get a successfull result', () => {
it('should return a Right container', () => {
expect(
String(tryCatch(() => 'hello-world'.split('-')))
).toEqual(
String(Right('hello,world'))
);
});
});
And the tryCatch
implementation:
import { Left, Right } from './Either.js';
/**
* Turns a try/catch into an `Either`, composable container.
*
* @param {Function} f
* @return {Either}
*/
const tryCatch = f => {
try {
return Right(f());
} catch (err) {
return Left(err);
}
};
export { tryCatch }
And here’s the getPort
implementation using the shiny and new
tryCatch
:
/// <reference path="../lib/typedefs.js" />
import { tryCatch } from '../lib/index.js';
import { readFileSync } from 'fs';
import {
log,
Left,
Right,
} from '../lib/index.js';
/**
* Gets the port from the config file.
*
* Returns the port from the config file or 3000 if impossible to get
* port from config file.
*
* @param {string} configPath The path the config file.
* @return {number}
*
* @example
* getPort(); // No path, or incorrect path.
* // → 3000
*
* But if we have a proper config file, with a proper path param, then
* we get back that port.
*
* $ cat ./configs/config.json
* {
* "port": 8888
* }
*
* @example
* getPort('./configs/config.json');
* // → 8888
*/
const getPort = (configPath) => {
return tryCatch(() => readFileSync(configPath))
.map(JSON.parse)
.fold(_ => 3000, objCfg => objCfg.port);
};
log(getPort());
// → 3000
log(getPort('./vid04/wrong-path.json'));
// → 3000
log(getPort('./vid04/config-04b.json'));
// → 8888
We are handling errors while attempting to read the file, but we may still get errors while parsing the JSON in case it is invalid.
v3 - Problems With Invalid, Unparsable JSON¶
Suppose the JSON is invalid:
$ cat vid04/config-04c.json
{
invalid: 'json'
}
This is the current implementation of getPort
:
const getPort = (configPath) => {
return tryCatch(() => readFileSync(configPath))
.map(JSON.parse)
.fold(_ => 3000, objCfg => objCfg.port);
};
The above would produce the following results:
log(getPort());
// → 3000
log(getPort('./vid04/wrong-path.json'));
// → 3000
log(getPort('./vid04/config-04c-invalid.json'));
// → SyntaxError: Unexpected token i in JSON at position 4
So, we can handle no file incorrect file path, but not invalid JSON.
v4 - tryCatch JSON.parse¶
Well, we can just tryCatch
JSON.parse
just like we are doing
with readfilesync
.
const getPort = (configPath) => {
return tryCatch(() => readFileSync(configPath))
.map(jsonCfg => tryCatch(() => JSON.parse(jsonCfg)))
};
log(String(getPort('./vid04/config-04c-invalid.json')));
// → Right(Left(SyntaxError: Unexpected token i in JSON at position 4))
We now have used tryCatch
twice, one to handle readfilesync
exceptions, and another one to handle JSON.parse
exceptions.
We intentionally did not fold
to attempt to return the port to
show a problem with this approach. If you pay attention to the result,
we have a Left
inside a Right
. That is, a container inside a
container. This situation makes it hard to reason about the
code… More over, if we just fold
after we parse the JSON, we get
undefined
because of the nesting of containers. Handling that
without touching our Either
implementation is possible but becomes
harder and harder as the nesting of containers gets deeper and deeper.
TODO: Add an example of how to the manual un-nesting of containers.
Explained in a different way:
const getPort = (configPath) => {
// <1>
return tryCatch(() => readFileSync(configPath))
.map(jsonCfg => tryCatch(() => JSON.parse(jsonCfg)))
// <2>
.fold(_ => 3000, objCfg => objCfg.port);
};
Return a
Right(fileContent)
because we can read the file just fine.Now we map, and try to parse the json, which is impossible because it is not valid json and it throws an exception, which tryCatch handles and returns a Left(err). Now we have a Right(Left(err)). A box within a box.
PROBLEM: We get a box inside a box… 😭
v5 Using .chain()¶
What we can do is to write a chain()
method to be used in place of
map()
sometimes.
Chain is just like map()
except we don’t “box it back up” so we end
up with one Left
or Right
afterwards.
function Left(value) {
return {
chain: _ => Left(value),
map: _ => Left(value),
fold: (leftFn, _) => leftFn(value),
toString: () => `Left(${value})`,
};
}
Left().map()
and Left().chain()
always ignore the function and
refuse to apply it (we are handling error conditions).
On the other hand, Right().map()
does apply the function and box
the result, while Right().chain()
/ applies the function but does
not box the value.
function Right(value) {
return {
chain: f => f(value),
map: f => Right(f(value)),
fold: (_, rightFn) => rightFn(value),
toString: () => `Right(${value})`,
};
}
Tip
Think like this: “If we are going to return another Either
,
use chain()
instead of map()
.” This is the golden tip to
know when to use chain()
instead of map()
.
Check the full definition of Either
in Either Implementation.
Then, the implementation of getPort()
using chain goes like this:
const getPort = (configPath) => {
return tryCatch(() => readFileSync(configPath))
.chain(jsonCfg => tryCatch(() => JSON.parse(jsonCfg)))
.fold(_ => 3000, objCfg => objCfg.port);
};
Then, even with invalid JSON, we still get a default port of 3000.
v6 Final Implementation¶
We finally reach our best implementation (from the point of view of FP).
getPort Unit Tests¶
import { getPort } from './vid04d.js';
describe('getPort()', () => {
describe('when file path is invalid', () => {
it('should return default port', () => {
expect(getPort('./wrong-path-config.json')).toEqual(3000);
});
});
describe('when json is invalid', () => {
it('should return default port', () => {
expect(getPort('./vid04/config-04d-invalid.json')).toEqual(3000);
});
});
describe('when path and json are both valid', () => {
it('should return port as defined in config file', () => {
expect(getPort('./vid04/config-04d-valid.json')).toEqual(8888);
});
});
});
getPort Implementation¶
/// <reference path="../lib/typedefs.js" />
import { readFileSync } from 'fs';
import { tryCatch } from '../lib/index.js';
/**
* Gets the port from the config file.
*
* Returns the port from the config file or 3000 if impossible to get
* port from config file.
*
* @param {string} configPath The path the config file.
* @return {number}
*
* @example
* getPort(); // No path, or incorrect path.
* // → 3000
*
* But if we have a proper config file, with a proper path param, then
* we get back that port.
*
* $ cat ./configs/config.json
* {
* "port": 8888
* }
*
* @example
* getPort('./configs/config.json');
* // → 8888
*/
const getPort = (configPath) => {
return tryCatch(() => readFileSync(configPath))
.chain(jsonCfg => tryCatch(() => JSON.parse(jsonCfg)))
.fold(_ => 3000, objCfg => objCfg.port);
};
export { getPort };
We do not check whether it is an error or not because we have a composable type that will do the right thing when mapped or folded.
Thoughts¶
We have learn how to use these utilities in context, how to combine
and use them appropriately for each given situation. chain()
is
defined just like our initial Box().fold()
was. Our current
fold()
implementation takes a left and a right function, and it
captures the idea of removing a value from its context (from its “Box”
or “Container”). chain()
expects use to run a function and return
another one. This is a key thing to keep in mind:
fold()
applies a function and unbox the value, which also means we cannot keep chaining invocations after that.chain()
applies a function, but returns another function, still able to be chained.