Skip to content

Instantly share code, notes, and snippets.

@cowboyd
Created May 12, 2021 01:54
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save cowboyd/bb54d0b9d496aac2f6258c7f759b69e6 to your computer and use it in GitHub Desktop.
Save cowboyd/bb54d0b9d496aac2f6258c7f759b69e6 to your computer and use it in GitHub Desktop.
Thoughts on evergreen boiler-plate configuration

Thoughts on evolving project boilerplate

Projects are bootstrapped with templates, and so before we can talk about evolving project boilerplate, we have to first talk about how templates work.

What is a template really?

We can represent the rendering of a template T into an artifact a with the equation

a = T(v)

where v is a data structure representing the input of T, or in practical terms, the "template variables". For example, we could use the following values to represent the rendering of a simple handlebars template.

T = "Hello {{name}}"
v = { name: "Charles" }
a = "Hello Charles"

Or, in pseudo-function syntax

hbs`Hello {{name}}`({ name: "Charles" }) //=> "Hello Charles"

This is a trivial example, but if the artifact generated by a template T is a pure function of v (its variables), then this same simple formulation can be used to represent the rendering of a template of arbitrary size and an input of arbitrary complexity.

By the same token, when we generate the artifact of "boiler plate config" at a project's inception, we do it according to the rendering of a template, and so even though that template may touch many files and be composed of many sub-templates, if it always generates the same artifacts from the same inputs, then it will still follow our original conception of it as an instance of the equation

a = T(v)

Representing evolution

At some point however, the template will change. For example, suppose that we want our hello artifact to greet rather quite vigorously instead of the bland salutation of the initial version. We can introduce a new template T' (pronounced "T prime") to render a new artifact a' (pronounced "a prime"). Using our template formula, we would represent it like so:

a' = T'(v)

In other words, we get a new artifact, by taking the new template, and using it to render with the old variables.

T' = "Hello {{name}}!!!"
v  = { name: "Charles" }
a' = "Hello Charles!!!"

What's interesting about this is that if we persist v, then that is all we need to generate the new artifact. We don't need the old template, and we don't need the old artifact. We just need the value of the original variables, v.

Variable variables

Actually, that last statement is an oversimplification. It holds true in the cases where the type (or schema) of the value v is constant, and so far this has been the case. If we annotate the type of the template variables in our equation, it points the way towards the complication.

a = T(v: V)

We rendered both of the artifacts a and a' using the same value, so naturally they had the same form. But what if the template T' does not take an input as the same type as T? To use pseudo code, what if the type of the original template function is

interface T {
  (variables: V);
}

but the interface of the subsequent template function is

interface T' {
  (variables: V');
}

In this case, we can't use our old value v to render because our new template function takes variables of type V', not V!

Let's make it concreate. What happens if we change our handlebars template to this:

Hello {{to}}!!!

Now, we can't use the same value { name: "Charles" } which was shaped like

interface V {
  name: string;
}

Because now our template needs variables shaped like this

interface V' {
  to: string;
}

What we really need to do is to somehow convert v which into { to: "Charles" } if we can.

Migrating variables

How can we convert { name: "World" } into { to: "World" }? Well, the simplest thing that could possibly work would be to just use a function.

If we have a function Migrate(v) such that v' = Migrate(v), then anywhere we previously had a value v of type V, we can now use it to generate a value v' of type V'

In our case we could define such a function (in TypeScript pseudo-code):

export function migrate(value: V): V' {
  return {
    to: value.name
  };
}

And then armed with this function and the new template definition, we can render the new artifact with the equation

a' = T'(Migrate(v))

When we think about this migration function what are the tasks that it could be called upon to perform? As I see it, there are two cases. In the first case, there is no human intervention required at all, and that is when the new value can be wholly derived from the existing value. However, this feels as though it would be exceedingly rare. After all, if it can be easily derived from the existing value, then why change things around at all?

This is conjecture, but in all probability, it will be more often the case where new values are introduced. In this case, our migration operation will have to seek input from the user.

If we model the initial setup and generation of project configuration as a migration from zero (where all values must be captured from the user), then we can have the migration process elegantly exapnd the set of template variables in an incremental fashion.

What this means in practice

The example above just shows a template function changing once from T -> T'. In reality however, template function will change many, many times over the course of its life, and so we don't just have a situation of two functions T and T', but instead a series of functions T1, T2, T3, ...., Tn

But, if we always have a migration function between Tn and Tn+1, then we can migrate from any template rendering Tj -> Tk (where k is greater than or equal to j) by running the migration functions between them in order on the starting value.

This means that inside the project, we need to store two things:

  1. the current template variables
  2. the integer version number of their schema

For example, in our case, we might have a .variables.json file:

{
  "schemaVersion": 1,
  "value": {
    "name": "World"
  }
}

Then, inside the repository where the template is defined, we need not only the template, but how to get from schema version 1 to schema version 2. For example, maybe we have a directory for each version of the template:

templates/greeting/1
                   - template.hbs
                   - migrate.ts
                  /2
                   - template.hbs
                   - migrate.ts

This might not be the optimal layout, but the basic idea is that every advance in the variables that the template consumes

How do we handle changes made to the artifacts themselves

However we organize it on disk, this forumation provides an adequate model for updating an artifact based on a newer version of a template. And up until this point, we've been operating on the asumption that the artifact is a pure function of the variables. However, that isn't quite the full story. There is still one challenges remaining, and that is the fact that developers may modify the output artifact in-place and independent from any template rendering. After all, this is what boiler plate is for. You generate it once to get you started, and then you manually tweak it from that point forward?

An example of this is the package.json file in npm init. You start with a bunch of variables: name, version, url, author, etc... and then you manually upgrade it from that point forward with npm add --save, etc.. . How would we say, upgrade the version of @frontside/typescript but still maintain any changes that you had put into place since creating the project in the first place?

I don't know the answer off the top of my head, but it feels like there are two options:

  1. extract variables from the current artifact and merge them with other variables.
  2. take the diff of the current template version rendered and treat it as the input to a second render phase
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment