BoolTable is an expressive and functional alternative for complex and incongruous conditional structures in Javascript. It allows one to build logical branches not unlike truth tables and decision tables.
With BoolTable you can write more reliable, consistent, expressive, observable, and testable condition branching.
npm i booltable
booltable
exposes three APIs: Truth
, Decision
, and BoolTable
.
This API is akin to a truth table row, which allows one to specify any number of statements that may be true or false, and apply a boolean operator to the collection to achieve a result.
For example:
- a table row of three items:
[true, true, true]
would returntrue
for anAND
operator,false
for aNOR
operator, etc; - a table row with
[true, false]
would returnfalse
forAND
, andtrue
forXOR
,true
forOR
, etc
This API is akin to a decision table, which allows one to specify an action when certain conditions evaluate as true
. One can use Truth
in concert to allow for complex and specific decisions.
This can be used to indicate a list of actions that are triggered upon a complex set of conditions.
This API allows one to construct a more expressive conditions in the Decision
table, for better logging and observability, and to avoid the necessity of adding comments explaining each complex evaluation.
This allows for more meaningful use of Decision
, especially when used with a complex set of conditions.
Each of these are designed to be used together to avoid deeply nested if
/else
/switch
/etc statements.
A simple example to start with that most people can relate to is how a thermostat works.
Let's first take a sneak peak of what our final code will be, to give a sample of the expressivity BoolTable can add:
const thermostat = table =>
Decision.of([
[table.q(`It's getting too cold in here`), turnOnFurnace],
[table.q(`It's warm enough now`), turnOffFurnace],
[table.q(`It's getting too hot in here`), turnOnAirConditioning],
[table.q(`It's cool enough now`), turnOffAirConditioning],
[true, nothingHappening]
]);
If a thermostat is set to "heat", it will turn on a furnace when below the threshold temperature. It will turn off when the temperature goes above the threshold, plus one.
If a thermostat is set to "cool", it will turn on air conditioning when below a threshold temperature. It will turn off when the temperature goes below the threshold, minus one.
Let's visualize first in an actual decision table:
mode | status | temperature | action to take |
---|---|---|---|
set to "heat" | currently off | thermometer is below threshold | turn the furnace on |
set to "heat" | currently on | thermometer is above threshold + 1 | turn the furnace off |
set to "cool" | currently off | thermometer is above threshold | turn the air conditioning on |
set to "cool" | currently on | thermometer is below threshold - 1 | turn the air conditioning off |
const turnOnFurnace = () => console.log('Turning on the furnace');
const turnOffFurnace = () => console.log('Turning off the furnace');
const turnOnAirConditioning = () =>
console.log('Turning on the air conditioning');
const turnOffAirConditioning = () =>
console.log('Turning off the air conditioning');
const nothingHappening = () => console.log('Nothing happening');
const thermostat = conditions =>
Decision.of([
[
conditions.mode === 'heat' &&
conditions.operating === 0 &&
conditions.thermometer < conditions.thresholdTemp,
turnOnFurnace
],
[
conditions.mode === 'heat' &&
conditions.operating === 1 &&
conditions.thermometer > conditions.thresholdTemp + 1,
turnOffFurnace
],
[
conditions.mode === 'cool' &&
conditions.operating === 0 &&
conditions.thermometer > conditions.thresholdTemp,
turnOnAirConditioning
],
[
conditions.mode === 'cool' &&
conditions.operating === 1 &&
conditions.thermometer < conditions.thresholdtemp - 1,
turnOffAirConditioning
],
// a default response if no action is necessary
[true, nothingHappening]
]);
// example conditions, this will turn off the furnace:
// get our action function
const actionFn = thermostat({
mode: 'heat',
operating: 1,
thermometer: 21,
thresholdTemp: 19
})
.run()
.join();
// console:
// > Turning off the furnace
actionFn();
While this works, the boolean statements make this code more fragile than it needs to be. Let's group our conditions with Truth
.
const thermostat2 = conditions =>
Decision.of([
[
Truth.of([
conditions.mode === 'heat',
conditions.operating === 0,
conditions.thermometer < conditions.thresholdTemp
]).and(),
turnOnFurnace
],
[
Truth.of([
conditions.mode === 'heat',
conditions.operating === 1,
conditions.thermometer > conditions.thresholdTemp + 1
]).and(),
turnOffFurnace
],
[
Truth.of([
conditions.mode === 'cool',
conditions.operating === 0,
conditions.thermometer > conditions.thresholdTemp
]).and(),
turnOnAirConditioning
],
[
Truth.of([
conditions.mode === 'cool',
conditions.operating === 1,
conditions.thermometer < conditions.thresholdtemp - 1
]).and(),
turnOffAirConditioning
],
[true, nothingHappening]
]);
// get our action function
const actionFn2 = thermostat({
mode: 'cool',
operating: 0,
thermometer: 24,
thresholdTemp: 22
})
.run()
.join();
// console:
// > Turning on the air conditioning
actionFn2();
This is better as one does not need to follow a list of items followed by &&
, but it is still a bit lengthy.
Let's make it more expressive, using Truth
in a clearer way.
const itIsGettingCold = conditions =>
Truth.of([
conditions.mode === 'heat',
conditions.operating === 0,
conditions.thermometer < conditions.thresholdTemp
]).and();
const itIsWarmEnough = conditions =>
Truth.of([
conditions.mode === 'heat',
conditions.operating === 1,
conditions.thermometer > conditions.thresholdTemp + 1
]).and();
const itIsGettingHot = conditions =>
Truth.of([
conditions.mode === 'cool',
conditions.operating === 0,
conditions.thermometer > conditions.thresholdTemp
]).and();
const itIsCoolEnough = conditions =>
Truth.of([
conditions.mode === 'cool',
conditions.operating === 1,
conditions.thermometer < conditions.thresholdtemp - 1
]).and();
const thermostat3 = conditions =>
Decision.of([
[itIsGettingCold(conditions), turnOnFurnace],
[itIsWarmEnough(conditions), turnOffFurnace],
[itIsGettingHot(conditions), turnOnAirConditioning],
[itIsCoolEnough(conditions), turnOffAirConditioning],
[true, nothingHappening]
]);
// get our action function
const actionFn3 = thermostat({
mode: 'cool',
operating: 0,
thermometer: 22,
thresholdTemp: 22
})
.run()
.join();
// console:
// > Nothing happening
actionFn3();
This is much more expressive, and readable for any developer who returns to the code in the future.
But what if we have a lot of options, and doing a camel-case const
for every single option is beginning to look odd?
This is where BoolTable
can help with adopting the expressivity of a unit test.
const bt = conditions =>
BoolTable.of([
[
`It's getting too cold in here`,
Truth.of([
conditions.mode === 'heat',
conditions.operating === 0,
conditions.thermometer < conditions.thresholdTemp
]).and()
],
[
`It's warm enough now`,
Truth.of([
conditions.mode === 'heat',
conditions.operating === 1,
conditions.thermometer > conditions.thresholdTemp + 1
]).and()
],
[
`It's getting too hot in here`,
Truth.of([
conditions.mode === 'cool',
conditions.operating === 0,
conditions.thermometer > conditions.thresholdTemp
]).and()
],
[
`It's cool enough now`,
Truth.of([
conditions.mode === 'cool',
conditions.operating === 1,
conditions.thermometer < conditions.thresholdTemp - 1
]).and()
]
]);
const thermostat4 = table =>
Decision.of([
[table.q(`It's getting too cold in here`), turnOnFurnace],
[table.q(`It's warm enough now`), turnOffFurnace],
[table.q(`It's getting too hot in here`), turnOnAirConditioning],
[table.q(`It's cool enough now`), turnOffAirConditioning],
[true, nothingHappening]
]);
const condTable = bt({
mode: 'cool',
operating: 1,
thermometer: 21,
thresholdTemp: 23
});
// get our action function
const actionFn4 = thermostat4(condTable)
.run()
.join();
// console:
// > Turning off the air conditioning
actionFn4();
While this is slightly more code, it is more expressive and can reduce the need for comments in the code.
If seeing something like Decision.of
, Truth.of
as an API looks unusual to you, that's ok! This is a oft-used pattern for functional libraries in Javascript, and the documentation here will attempt to cover anything you'll need to know.
Truth.of([ condition1, condition2, ...]);
Create a truth table. This allows you to collect multiple conditions and assess them with boolean logic.
.and()
, .or()
, .xor()
, .nor()
Running any of these methods will return a true
or false
value.
Truth.of([true, true]).and(); // === true : AND operation means all are true
Truth.of([false, false]).and(); // === false
Truth.of([true, false]).or(); // === true : OR operation means at least one is true
Truth.of([false, false, false]).or(); // === false
Truth.of([true, false, false]).xor(); // === true : XOR operation means at least one is true and at least one is false
Truth.of([true, true, true]).xor(); // === false
Truth.of([false, false, false]).nor(); // === true : NOR operation means all are false
Truth.of([true, false, true]).nor(); // === false
// applying an example
const x = 2;
// check a simple range
Truth.of([x < 0, x > 3]).or(); // === false
// check a simple range plus type
Truth.of([x < 3, x > 0, typeof x !== 'string']).and(); // === true
// validate `x` is not an unwanted type
Truth.of([typeof x !== 'string', typeof x !== 'undefined']).and(); // === true, or, written another way....
Truth.of([typeof x === 'string', typeof x === 'undefined']).nor(); // === true
// check a complex range
Truth.of([x > 1 && x < 6, x === 71, x > 656 && x < 765]).xor(); // === true
// make the complex range more scalable and expressive!
const lowRange = y => Truth.of([x > 1, x < 6]).and();
const higherRange = y => Truth.of([x > 656, x < 765]).and();
Truth.of([lowRange(x), x === 71, higherRange(x)]).xor(); // === true
The following uses the functional programming notions of Left
/L
(failure branch), and Right
/R
(successful branch) to be consistent with functional styles. In short, L
= false, R
= true. Failure first, then success.
.forkAnd(functionL, functionR)
, .forkOr(fL, fR)
, .forkNor(fL, fR)
, .forkXor(fL, fR)
The fork
methods will run the first function provided if there is a false
value, the second if there is a true
value.
Useful for replacing if
/else
ternary branching. "Run one function if false, another function if true", for example.
const x = Truth.of([true, true]);
x.forkAnd(
() => console.log('false branch, will NOT issue this console message!'),
() => console.log('true branch, will issue this console message!')
);
x.forkNor(
() => console.log('false branch, will issue this console message!'),
() => console.log('true branch, will NOT issue this console message!')
);
Additionally, there are left (fL
), and right (fR
) -only versions that will run one function under the branch specified, and run no functions at all if that branch is not encountered.
.forkAndL(fL)
, .forkAndR(fR)
.forkOrL(fL)
, .forkOrR(fR)
.forkNorL(fL)
, .forkNorR(fR)
.forkXorL(fL)
, .forkXorR(fR)
This is useful for replacing simple if
conditions. "Run function if true", for example.
const x = Truth.of([true, false]);
x.forkAndL(() => console.log('false branch, will issue this console message!'));
x.forkAndR(() =>
console.log(
'true branch, will NOT issue this console message, or do anything at all!'
)
);
This is a simple functional concept for debugging purposes.
.inspect()
In case you want a peek inside a Truth
statement, you can use this to convert the value to a string.
const a = 1;
const x = Truth.of([a > 0, a > 100]);
x.inspect(); // "Truth(true,false)"
BoolTable.of([ [string, condition], [string, condition], ... ]);
Create a descriptive conditions table. Each row takes two values, a string
and a condition that evaluates to true
or false
.
.query(string)
, or .q(string)
Simply pass a string and it will return its matching condition. Useful to create expressive condition queries.
This API is best used in a Decision
table, but the example below shows how it might work independent of that.
const isEven = x => Truth.of([typeof x === 'number', x % 2 === 0]).and();
const isOdd = x => Truth.of([typeof x === 'number', x % 2 !== 0]).and();
const t = v =>
BoolTable.of([
['the value is an even number', isEven(v)],
['the value is an odd number', isOdd(v)],
['the value is not a number', isNaN(v)]
]);
const testString = t('string');
// > true
console.log(testString.q('the value is not a number'));
// > false
console.log(testString.q('the value is an odd number'));
// > false
console.log(testString.q('the value is an even number'));
This is a simple functional concept for debugging purposes.
.inspect()
In case you want a peek inside a BoolTable
statement, you can use this to convert the value to a string.
Extending the previous example:
const testString = t('string');
testString.inspect();
// BoolTable(the value is an even number,false,the value is an odd number,false,the value is not a number,true)
Decision.of([ [condition, (value | function) [, fnParameter]], [...], ... ]]);
Create a decision table, which may either return a value, or execute a function with a given value.
This is easier to show in an example than to document by normal means.
const mergeObj = (objs) => objs.reduce((acc, cur) => Object.assign(acc, cur));
const age = 22;
const permissions = Decision.of([
[age > 15, { allowedToDrive: true }],
[age > 18, { allowedToVote: true }],
[age > 21, { allowedToDrinkAlcohol: true }],
[age > 65, { allowedSeniorsDiscount: true }]
]).run('any').chain(mergeObj); // (chain takes resulting values and passes to a function)
// > { "allowedToDrinkAlcohol": true, "allowedToDrive": true, "allowedToVote": true }
.run(method)
This will run through the table using either a specified method, or by default, just the first true
row.
first
: (default) Run only first true
row encountered
last
: Run only the last true
row encountered
any
: Run any true
rows encountered, returning results as array
2
: (any positive integer) Run only the first 2 rows encountered, returning result as array
There are two ways to use Decision
: to return specific values, or to run specific functions (that may or may not return values).
If you require values, you will need to follow .run()
with an unwrap method like .join()
or .chain()
, otherwise they will be inaccessible. (If you do not require values back, you need not add an unwrap method call.)
.join()
will unwrap the results as they are. .chain()
will pass them to a function. (These are both concepts from functional programming.)
Advanced section!
If you're familiar with functional programming, you might recognize Truth
, Decision
, and BoolTable
to be monads. This means they comes with standard, out-of-the-box methods.
.ap(M)
, .chain(f)
, .map(f)
, .join()
Those all do what you'd expect of a monad, they obey the monad laws. See the unit tests for examples, and proof of monad law compliance.
.concat(M)
, .head()
, .tail()
, .isEmpty()
Because these are typed monads that contains an array, certain array-based methods have been added.
Only .concat
takes a parameter, and that parameter must be another monad of the same type.
Below are some example techniques for how the API might be used.
import { Truth, Decision, BoolTable } from 'booltable';
// simplest: [Condition, Result]
const makeDecision = x =>
Decision.of([
[x > 1, 2],
[x === 1, 3],
[x === 0, 3.5],
[(!x, 4)],
// default, always true
[true, 10]
]);
// result1 === 2;
// uses default 'first', meaning first true result returns
const result1 = makeDecision(2)
.run()
.join();
// result2 === [3.5, 4, 10];
// 'any' means any conditions are true, returns array of results
const result2 = makeDecision(0)
.run('any')
.join();
// result3 === 10
// 'last' means last true result returns
const result3 = makeDecision(101)
.run('last')
.join();
// result4 === [3.5, 4];
// '2' means first two true conditions -- this can be given positive integer
const result4 = makeDecision(0)
.run(2)
.join();
const data = {
aa: 1,
bb: true,
cc: [0],
dd: { test: 'a' },
ee: false
};
// Taking an array parameter here allows memoization of the function
// This means one could use a memoizer to ensure `result5` through `result8` only execute this function once
const conditions = ([a, b, c]) => Truth.of([a, b, c]);
// result5 === true
// .or() means apply "OR" to these conditions, since data.aa is greater than 0, this returns true
// OR means at least one is true
const result5 = conditions([
data.aa > 0,
data.bb === true,
data.cc.length
]).or();
// result6 === false
// .xor() means apply "XOR" to these conditions, since all items are true, this would return false
// XOR means at least one is true and at least one is false
const result6 = conditions([
data.aa > 0,
data.bb === true,
data.cc.length
]).or();
// result7 === false
// .nor() means apply "NOR" to these conditions, since all items are true, this would return false
// NOR means all are false
const result7 = conditions([
data.aa > 0,
data.bb === true,
data.cc.length
]).nor();
// result8 === true
// .and() means apply "AND" to these conditions, since all items are true, this would return true
// AND means all are true
const result8 = conditions([
data.aa > 0,
data.bb === true,
data.cc.length
]).nor();
// since the results evaluate to `true` or `false`, can be used in Decision table
Decision.of([
[result5, 3],
[result6, 2],
[result7, 3],
[result8, 0],
// default, always returns true
[true, 10]
]);
This is an example of how Truth
can be used like a truth table, using the methods provided.
const a = 2;
const b = 3;
const c = 15;
const d = 150;
const logic1 = Truth.of([a > 11, b === 2, !c, d - 1 >= 100]);
const logic2 = Truth.of([a < b, b !== 4, c > a, d < 1000]);
const logic3 = Truth.of([a < b, b === 4, c === a, d > 1000]);
const | I | II | III | IV | .and() | .or() | .nor() | .xor() |
---|---|---|---|---|---|---|---|---|
logic1 | a > 11 | b === 2 | !c | (d - 1) >= 100 | false | true | false | true |
logic2 | a < b | b !== 4 | c > a | d < 1000 | true | true | false | false |
logic3 | a < b | b === 4 | c === a | d > 1000 | false | false | true | false |
This can be also created using a more expressive API, BoolTable
.
const tt = BoolTable.of([
['is logic1 true with and?', logic1.and()],
['is logic2 true with and?', logic2.and()],
['is logic3 true with xor?', logic3.xor()]
]);
// false
tt.q('is logic1 true with and?');
// true
tt.q('is logic2 true with and?');
// false
tt.q('is logic3 true with xor?');
This is an example of how Decision
can be like a decision table.
const makeDecision = x =>
Decision.of([
[x > 1, warn('reading is too high')],
[x === 1, notice('things look good')],
[x === 0, warn('reading is zero')],
[x === undefined, error('no reading found')],
// default, always true
[true, error('reading is out of range')]
]);
condition 1 | action |
---|---|
x > 1 | warn('reading is too high') |
x === 1 | notice('things look good') |
!x | warn('reading is zero') |
x === 0 | warn('reading is zero') |
x === undefined | error('no reading found') |
default | error('reading is out of range') |
Of course, this is very basic. One can the use Truth
in combination to identify multiple conditions.
// we use ([x, y, z]) so the fns are still memoizable even though there are multiple params
const cond1 = ([x, y, z]) => Truth.of([x > 1, y > 1, z > 1]).and();
const cond2 = ([x, y, z]) => Truth.of([x > 1, y < 1, z < 1]).and();
const cond3 = ([x, y, z]) => Truth.of([x < 1, y < 1, z < 1]).and();
const makeDecision = ([x, y, z]) =>
Decision.of([
[cond1([x, y, z]), warn('all readings high')],
[cond2([x, y, z]), warn('x is high')],
[cond3([x, y, z]), notice('all readings in normal range')]
]);
makeDecison([1.25, 0.5, 0.1]).run();
// result runs `warn('x is high')`
Note that one could also write the above with three items in each row:
const makeDecisionExpanded = ([x, y, z]) =>
Decision.of([
// condition(s), fn, argument
[cond1([x, y, z]), warn, 'all readings high'],
[cond2([x, y, z]), warn, 'x is high'],
[cond3([x, y, z]), notice, 'all readings in normal range']
]);
Let's add some expressivity:
const bt = ([x, y, z]) =>
BoolTable.of([
['are all the readings high?', cond1([x, y, z])],
['is x high?', cond2([x, y, z])],
['are things normal?', cond3([x, y, z])]
]);
const makeDecisionExpanded = (
vals // vals === Array [x, y, z], more readable and still memoizable
) =>
Decision.of([
// condition(s), fn, argument
// are/is interchangable
[bt(vals).q('are all the readings high?'), warn, 'all readings high'],
[bt(vals).q('is x high?'), warn, 'x is high'],
[
bt(vals).q('are things normal?'),
notice,
'all readings in normal range'
]
]);
All these result in a decision table like this:
condition 1 | condition 2 | condition 3 | action (AND) |
---|---|---|---|
x > 1 | y > 1 | z > 1 | warn('all readings high') |
x > 1 | y < 1 | z < 1 | warn('x is high') |
x < 1 | y < 1 | z < 1 | notice('all readings in normal range') |
This is a very basic example of a decision table, and is only using .and()
in the calculation of true/false. One could have much more complex logic.
Source is written in TypeScript. Run tests via npm run test
.
Copyright 2018-2019 Robert Gerald Porter mailto:rob@weeverapps.com
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.