Recently I made a minimalistic library for coloring text, surrounding text with a box, and outputting JavaScript objects as a tree with keys, values, and metadata (types). At first, I also made a function for printing text that I, in the end, removed.
This text is an attempt to hint at why it was a good idea to remove this specific feature, but also how this fits with a larger more philosophical, architectural view on programming.
By separating code and data, I got something more open, bordering to the
world only by string values. Instead of printing the string values to
stdout, the API user can decide what to do with the produced values -
print it to screen using console.log,
process.stdout.write, enclose it in an HTML
pre tag, or some other side-effect or computation involving
strings.
const { boxify, fmtStr } = require("crude-dev-tools");
const str = `
Hello world!
This is a box.
`;
// un-colored
console.log(boxify(str));
// blue colored
console.log(fmtStr(boxify(str), "blue"));
Returns a string without colors (without ANSI codes for colors) and with
colors (using ANSI):
const { logObj } = require("crude-dev-tools");
const obj = {
a: {
b: 123,
c: "Hello, world!",
d: [1, 2, 3],
e: {
f: true,
},
},
};
console.log(logObj(obj));
// console.log(logObj(obj, false) -- no colors
outputs,
It’s always a temptation to provide the service of outputting results, but I believe we make ourselves, and others, a greater service by delegating this responsibility to the API user, keeping with the essence of the Unix philosophy, trusting users, and thereby keeping an API open, composable, and clean.
Another effect of this openness is that it's easier to minimize the amount of code needed, make sure those results are predictable and accurate. Besides predictability and accuracy, a function which borders to its outside with mere values is easier to reason about, especially about how to combine it with other APIs following the same kind of philosophy.
In a talk, Gary Bernhardt speaks about a pragmatic style of programming that loosely builds upon the heuristic of ‘functional core, imperative shell’.
Having a functional core means isolating all computations, and organizing them as isolated units - either self-contained or at least only dependent on other isolated units. A unit is here a function in the mathematical sense, a machine taking some input, delivering a computed output. The same input to a pure function is guaranteed to have the same output.
The imperative shell is a name for functions that either directly interacts with its environment through side effects, or indirectly by dependency.
Following this heuristic, a clean, testable, and presumable readable codebase follows; a codebase that isolates all side-effects - be it interactions with the user, database requests, or outputting to the screen or some other device - functionality that doesn’t have the same certainty as pure functions.
If code whose purpose is to handle integrations is isolated - keeping the purity of functions dealing with values and computation, goes hand in hand with isolating integrations - its boundaries get more visible, and more opaque parts become more transparent. It’s most likely also easier to see where things go wrong, to debug.
Clean and readable are interrelated, but neither cleanness nor readability is intrinsic properties in code, and it’s impossible to produce pure technical arguments for what such notions should mean.
Following standards similar to ‘literary criticism’ and related fields, most people, I think, would agree that it’s easier to read pure functions (often written with only a few lines of code) since no or little (if they’re dependent on other pure functions) tracking is necessary for understanding. Besides from naming conventions, this aspect is arguable immensely important for understanding functions and how it relates to other parts of the code.
Another aspect is its purity, its mathematical nature. An isolated function with no side effects can be expected to produce perfect results (regardless of whether the results are the results we wanted, I should add), reliable and unambiguous in contrast to a function containing an HTTP request, and therefore relying upon an external dependency, its server status, etc.
All things mentioned make pure functions easier to reason about, and also make them, for technical reasons, easier to test since it's clear what to expect from a given input.
The success or failure of impure functions depends on external factors which makes them hard to test; pure functions are testable by default. Mocks and stubs are strictly speaking alien notions in the sphere of functional programming, indications that the code needs refactoring. When values are boundaries to the outside, on the other hand, we obtain composability.
My small lib contains,
and fills a narrow, but clear purpose (for me). The codebase is quite tiny:
const HEAD = 0;
const colors = {
black: 30,
red: 31,
green: 32,
yellow: 33,
blue: 34,
purple: 35,
cyan: 36,
white: 37,
start: '\x1b[',
bold: '1;',
reset: '\x1b[0m',
background: 10,
ending: 'm',
};
const colorsForTypes = {
array: 'white',
object: 'red',
null: 'purple',
Map: 'purple',
Set: 'purple',
undefined: 'purple',
string: 'green',
number: 'yellow',
boolean: 'cyan',
};
const _concat = (fst, snd) =>
typeof fst === 'string' ? ''.concat(fst, snd) : [].concat(fst, snd);
const concat = (fst, snd) =>
snd === undefined ? (_snd) => _concat(fst, _snd) : _concat(fst, snd);
function makeAnsiStyle(fgColor = '', bgColor = '', formatted = '') {
const tempFormated = colors[formatted] || '';
const fg = colors[fgColor]
? `${colors.start}${tempFormated}${colors[fgColor]}${colors.ending}`
: '';
const tempBg = (colors[bgColor] + 10).toString() || '';
const bg = colors[bgColor] ? `${colors.start}${tempBg}${colors.ending}` : '';
return fg + bg;
}
function compose(...fns) {
return function (val) {
for (let i = fns.length - 1; i >= 0; i--) {
val = fns[i](val);
}
return val;
};
}
function fmtProp(key, type, level, value = '') {
const prefix = level > 0 ? `${' '.repeat(level * 2)}` : '';
const valFmt = fmtStr(value, 'purple');
return concat(
fmtStr(`${prefix}[${key}]: ${valFmt}`, 'blue', 'bold'),
fmtStr(` <${type}>\n`, colorsForTypes[type], 'bold')
);
}
function whichType(entity) {
if (entity === null) return 'null';
if (Array.isArray(entity)) return 'array';
if (entity instanceof Map) return 'Map';
if (entity instanceof Set) return 'Set';
return typeof entity;
}
function fmtObj(valType, shouldUseColors, acc, val, key, level) {
return valType === 'object'
? compose(
concat(fmtProp(key, 'object', level, null)),
concat(logObj(val, shouldUseColors, level + 1))
)(acc)
: acc.concat(fmtProp(key, valType, level, val));
}
const createLns =
(level, object, shouldUseColors) =>
(acc, [key, value]) =>
fmtObj(whichType(object[key]), shouldUseColors, acc, value, key, level);
function logObj(object, shouldUseColors = true, level = 0) {
const coloredStr = Object.entries(object).reduce(
createLns(level, object, shouldUseColors),
''
);
return shouldUseColors ? coloredStr : purify(coloredStr);
}
const fmtLn = (ln, w) => `║ ${ln}${' '.repeat(w - ln.length)} ║`;
const fmtLns = (arr, w) => arr.map((ln) => fmtLn(ln, w));
const isLonger = (a, b) => b.length - a.length;
const longestLnLenOf = (lns) => [...lns].sort(isLonger)[HEAD].length;
const topLn = (w) => `╔${'═'.repeat(w + 2)}╗\n`;
const bottomLn = (w) => `\n╚${'═'.repeat(w + 2)}╝\n`;
function boxify(str) {
const lns = str.split('\n') || [str];
const longestLnLen = longestLnLenOf(lns);
const formattedLines = fmtLns(lns, longestLnLen).join('\n');
return compose(
concat(topLn(longestLnLen)),
concat(formattedLines)
)(bottomLn(longestLnLen));
}
const fmtStr = (text, fgColor = '', bgColor = '', formatted = '') => {
const textStyles = makeAnsiStyle(fgColor, bgColor, formatted);
return `${colors.reset}${textStyles}${text}${colors.reset}`;
};
// borrowed from https://newbedev.com/remove-all-ansi-colors-styles-from-strings
const purify = (str) =>
str.replace(
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
''
);