Bruno Schaatsbergen website Mastodon PGP Key email A drawing of an astronaut in space The Netherlands

Rethinking Configuration Languages (Part 1)

state
experiment
in
writing
date
4/23/2025

🧠 Experiment

These articles aren’t proposals—they’re simply ideas, meant to encourage thought and create conversation.

Back when I taught Terraform workshops, I’d give the class one assignment: write a module for the same small AWS architecture. Twenty students, twenty different modules. None of them wrong. None of them the same.

That’s not a story about the students. It’s a story about HCL.

I’ve worked with HCL for years and I’ll defend its place in the industry to anyone who’ll listen. But I keep running into the same friction: the language is permissive enough that two engineers solving the same problem will hand you two configurations that share almost nothing. Different shapes, different idioms, different ways of expressing the same intent. We accept it because the alternative is to write JSON.

Permissiveness is the feature and the bug

HCL was built to be flexible, and that flexibility is why it scales from a single S3 bucket to a multi-region landing zone. The cost is that there’s no canonical way to write almost anything. Every team grows its own dialect. Every codebase reads slightly differently. When something breaks, the error messages don’t push you toward a better pattern; they tell you the parser is confused.

A quick aside before I go further: HCL is the language, Terraform is one of its users. I’m picking on the language here, not the tool.

I came across this snippet in a real module recently. It’s semantically valid. It also took me three reads to figure out what it does:

locals {
  invalid_rule_map = transpose({
    for x in toset(var.update) :
    x => toset([
      for order in var.orderings :
      order[1]
      if tonumber(order[0]) == tonumber(x)
    ])
  })
  valid_update = alltrue([
    for index in range(0, length(var.update) - 1) :
    length(setintersection(
      lookup(local.invalid_rule_map, var.update[index], toset([])),
      slice(var.update, index + 1, length(var.update)),
    )) == 0
  ])
}

Now compare that to the part of HCL that actually sings, a block that mirrors the underlying API so closely you can read it without any context:

resource "aws_ecr_repository" "runtimes" {
  name                 = "runtimes"
  image_tag_mutability = "IMMUTABLE"

  image_scanning_configuration {
    scan_on_push = true
  }
}

Both of those are HCL. One is what we mean when we talk about declarative infrastructure. The other is what happens when a configuration language lets you write code like it’s a general-purpose programming language, without giving you the tooling, debuggers, tests, or type checks, to do that safely.

Linters won’t fix this

The usual answer is to bolt on a linter. Linters help. I run them. But they’re cleaning up after a design decision, not changing it. A linter will flag the cryptic transpose block; it won’t make it harder to write. The root cause is that the language allows it in the first place, and there’s no clear “right way” for the linter to push you toward, only the team’s local convention.

A linter can tell you your code is ugly. It can’t tell you it should have been a different shape entirely.

What I’m actually arguing for

I’m not proposing hcl/v3. I don’t want to throw HCL out. What I want is a configuration language built for cloud-native infrastructure that takes the patterns we’ve learned are good and forces them. Not “encourages.” Forces. The same way gofmt forces a single style on Go and everyone simultaneously hates it and loves it.

Configuration languages have spent the last decade quietly accumulating features from real programming languages: loops, conditionals, function-like expressions, deep interpolation, custom operators. Some of that was necessary. A lot of it wasn’t. Every borrowed feature came with the implicit promise that you’d get the expressiveness of a programming language without paying the debugging cost. But you don’t get a debugger. You don’t get a type system worth the name. You don’t get tests in any serious sense. So you get the footguns without the safety net.

The way out isn’t more linters or more best-practices docs. It’s a language that’s smaller on purpose. One that takes opinions on what we already know causes pain (how loops compose, how conditionals nest, how expressions chain) and refuses to bend on any of it. One where two engineers writing the same thing produce roughly the same code, because the language has already decided what shape it should be.

This is the bargain gofmt made. It killed the idea of a house style. There was only the style gofmt had decided on. You wrote that one, or you fought the formatter every day. People grumbled. Then code reviews stopped arguing about whitespace and started arguing about things that actually mattered. That’s the trade I want for configuration. Less freedom in exchange for predictability you can rely on across teams, across companies, across years.

Most businesses are doing roughly the same things: running some applications, setting up a landing zone, wiring identity together. There’s no real reason the resulting code should look as different as it does from one company to the next. The flexibility in the tools is what lets it. Boring configuration is good configuration.

Why this matters more for declarative config

Application code has scaffolding around it: a type checker, a debugger, a stack trace, a test suite, a REPL. Write a clever one-liner in Python and the surrounding tools catch you when you’re wrong. Configuration has none of that. The cryptic transpose block earlier in this post has no tests around it, no debugger to step into, no way to run it in isolation. You find out it’s wrong when the plan looks weird, when a resource gets recreated in production, or when an audit flags a setting nobody can explain.

There’s a second problem that’s specific to declarative languages. The whole point of declarative is that you describe what should exist, not how to get there. The runtime figures out the order. The moment you embed clever imperative expressions inside that declaration, you’re smuggling procedural logic into a document that’s supposed to be a contract. The next reader has to mentally execute your code to understand the intent. That’s the opposite of what declarative is for.

And configuration is read by more people than application code. Humans read it (security reviewers, SREs, the engineer who joins next year). Tools read it (drift detection, policy engines, scanners). The runtime itself reads it. Cleverness optimized for the author makes life harder for every one of them.

That’s why the case for restraint is stronger here. The blast radius is wider, the safety net is thinner, the audience is bigger, and every clever expression chips away at the declarative property the language is supposed to give you in the first place.

The lineage matters

None of this is a new conversation. NGINX’s config format pushed the industry toward declarative configuration. UCL made config files readable while staying JSON-compatible. HCL took the best of both and shipped something a generation of infrastructure engineers built their careers on. Each was an improvement on what came before. None of them is the final word.

What’s next

The rest of this series gets specific. I’ll walk through individual design decisions I’ve made (the ones I think hold up, and the ones I’m still uncertain about) and where they break from HCL on purpose. Some of those calls will turn out wrong. That’s fine. The interesting part is the constraints, not the language they produce.

If you want to push back on any of this, I’m at b@bschaatsbergen.com. I’d rather argue about the parts I have wrong than keep talking to myself about them.

/rethinking-configuration-languages-(part-1)