Programming by Configuration
10 JanuaryMake vs Ant, Gulp vs Grunt, config.js
vs config.json
.
What do these have in common? They represent the battle between flexibility and rigidity. Enabling vs restricting.
Ant (XML files), Grunt (javascript configuration files), json config files, and other non-language config files generally
- suck compared to a simple script, and
- grow over time to include language constructs like conditionals, loops, loading data, loading other files, etc.
It’s practically declaring “Let’s not write code when we can write config instead!”. Sounds terrible, and history has explored this time and again. It ends up with “recipes”, lists of functions, odd syntax, and flaky workarounds. Complication grows from writing code in code that runs code. And if that sounds redundant, that’s because it is. Code already is code.
That’s a lot of words. Let’s consider a modern replay of this dynamic: the javascript repeat of history:
Consider the many options of writing a simple “recipe” in code (a.k.a. a function):
Plain JS with try/catch
// It's just javascript. Reads fine but not great. Boring - but that's good.
function recipe(...args) {
let result
try {
result = add(...args)
result = multiply(4, ...result)
report(result)
} catch(error) {
logError(error)
}
)
recipe()
Plain JS with yoda structure
// Fuck this.
function recipe(...args) {
try {
report(multiply(4, ...add(...args)))
} catch(error) {
logError(error)
}
)
recipe()
Plain JS with “thenable”
// Reads fine, but at what cost?
function recipe(...args) {
add(...args)
.then(result => multiply(4, ...result))
.then(report)
.catch(logError)
)
recipe()
Plain JS with pipeline operator
// (in proposal stage with babel plugin)
// Reads a bit better despite the extra symbols.
function recipe(...args) {
try {
args
|> add(...#)
|> multiply(4, ...#)
|> report(#)
} catch(error) {
logError(error)
}
)
recipe()
Plain JS with chaining
// Not good, but then again, not the right time to use chaining.
function recipe(...args) {
try {
let result = Math
.add(...args)
.multiply(4)
report(result)
} catch(error) {
logError(error)
}
)
recipe()
Plain JS with decorator
// It's just javascript. Reads better. Still boring in a good way.
@onError('logError')
function recipe(...args) {
let result
result = add(...args)
result = multiply(4, ...result)
report(result)
)
recipe()
Plain JS with pipeline AND decorator
// (in proposal stage with babel plugin)
// Reads better still.
@onError('logError')
function recipe(...args) {
args
|> add(...#)
|> multiply(4, ...#)
|> report(#)
)
recipe()
- Versus -
And now let’s compare the above to the “programming by configuration” version - a deliberate pattern utilized extensively by RxJS for writing configuration instead of code, in code, as lists of arguments:
Argument Lists (e.g. RxJS Pipes)
// A list of functions returning functions passed as arguments to a recipe builder to be run later. Nicer to read than most examples here, but non-trivial cases will favor the others. In practice, this is the worst in the list.
let recipe = buildCodeRecipe(
add(),
multiply.bind(4),
report(),
catchError(error => logError(error)),
)
recipe.run()
The example doesn’t look so bad - what’s wrong with it?
- We’re using a list of arguments to represent lines of code. We can already represent lines of code by using… *checks notes* lines of code.
- It’s using a “runner” which will run the code returned by the higher-order functions as arguments. Well, we can already run code with or without the higher-order functions by… *checks notes again* just… running the code…
- Stacktraces are now 95% useless. This is a deal-breaker for anybody who’s supported large codebases.
Implementation, mental model, testing complexity, workarounds, rewrite of existing language constructs… This approach is not right for general programming.
But at least that’s still pseudo-javascript.
If we take it further to consider JSON
/YAML
/XML
“scripts” (from real config files):
YAML with arbitrary “condition”
- task: Pack@2
displayName: 'Pack'
condition: and(succeeded(), startsWith(variables['sourceBranch'], 'refs/tags/'))
inputs:
command: 'pack'
packagesToPack: 'src/**/*.csproj'
versioningScheme: 'byEnvVar'
versionEnvVar: 'VERSION'
XML with arbitrary “variables”
<target name="jar" depends="compile">
<jar destfile="${dist.dir}/build.jar" basedir="${build.dir}">
<manifest>
<attribute name="MainClass" value="test.Main" />
</manifest>
</jar>
</target>
JSON with hard-coded “filter” conditions
{
"concat": {
"foo": {
"files": [
{
"src": [
"src/a.js", "src/aa.js"
],
"dest": "dest/a/",
"filter": "isFile"
},
],
},
},
}
Javascript
/Ruby
/Python
/whatever
configuration files win hands-down. It’s a competition for general scripting capabilities in non-turing-complete vs. turing-complete languages… We can use a preprocessor to make the non-turing-complete config file turing-complete, but… hear me out… we could also not.
A turing-complete configuration file is fundamentally a callback with API access. You can’t imagine the possibilities, and that’s the whole point. A JSON
/XML
/YAML
configuration file inherently limits possibilities. Nobody puts Baby in a corner.
(There are uses for simple configs in simple JSON
/XML
/YAML
config files, too, but when you start implementing language constructs like loops, variables, etc…. stop. Fun story: I encountered a higher-order-function-equivalent in a set of YAML
config files today.)