• Introducing CCL

    In package.json considered harmful, I made the case that JSON is a poor config format and that JSONC and JSON5 don’t actually fix the problem — they just extend the lifespan of a bad bet. If comments are a requirement (and I think they are), and if simplicity matters, then the JavaScript ecosystem’s default config story is genuinely broken.

    But pointing out what’s wrong is the easy part. What should you use instead?

    I’ve been thinking about this for a while, and I want to tell you about a language I stumbled onto recently that I think gets it right — or at least, gets closer than anything else I’ve tried.

    The second criterion

    I said in package.json considered harmful that comment support is the minimum bar for a config language. But there’s a second criterion I didn’t spend much time on: simplicity. It needs to be simple to hand-author, simple to read, and simple to understand.

    This sounds obvious, but it’s surprisingly easy to fail. Which brings me to my first encounter on the road to finding something better.

    Discovering PKL (and rejecting it)

    I’ve been using mise for managing dev tools — it’s excellent for polyglot environments where you’re juggling Rust and Node.js toolchains simultaneously. The same author released a tool called HK, a Git hook manager. I tried it out, and the way HK is configured is using a language called PKL.

    I’d never heard of it. So I started researching, and wow — PKL is not a configuration language. PKL is a programming language disguised as a configuration language. It’s far too complicated. It has far too many features.

    PKL does support comments, so it clears the first bar. But it fails the second one badly. At the point where your config language needs a runtime and an execution model, the honest question is: why aren’t you just writing code? The complexity cost is real; the benefit over simply using a programming language is not.

    Machines are, in some ways, secondary to this conversation. We believe this about programming languages — nobody designs a language to make it easier for the computer. We design syntax and semantics to help the programmer, then do extra work in the compiler. We go to all that trouble because we believe people are more productive in languages that are easier to read and write.

    Config languages carry the same obligation. They should be easy to read, easy to write by hand. PKL seems to have forgotten this.

    Finding CCL

    While I was working through PKL’s documentation and finding new things to be frustrated by, I came across a blog post by Dmitrii Kovanikov. I found myself nodding along, saying “yes, this all makes sense.” It was about a configuration language Dmitrii called Categorical Configuration Language.

    What makes CCL elegant

    Consider how you’d jot down a list on paper. You’d probably write a heading, indent a bit underneath it, maybe use a dash for items. If something belongs under another item, you’d indent further. Maybe it would look something like this:

    Errands
    - Groceries
    Fruit
    - Apples - 2 lb
    - Bananas - 1 bunch
    - Crudite platter
    - Cereal
    - Milk
    - Vet - Spot's surgery ($500 deposit, ask about "recovery diet")

    Here’s what a basic CCL config looks like:

    /= Errands
    Groceries =
    Fruit =
    Apples = 2 lb
    Bananas = 1 bunch
    = Crudite platter
    = Cereal
    = Milk
    Vet = Spot's surgery ($500 deposit, ask about "recovery diet")

    That’s it. Keys and values separated by =. Comments are just entries with / as the key — not special syntax, just a convention. Nested values are indented. Lists are created by empty keys — much like the human-made list might use a dash to denote a list item. There’s nothing else to learn.

    I know there’s a cohort of programmers for whom significant indentation is an unforgivable sin. I have genuinely mixed feelings about YAML, which also uses indentation for structure, and I understand the frustration. But in CCL, the indentation is doing something different — and the distinction matters.

    The key insight is this: every time a language uses a special character to delimit structure — {, }, [, ], ", | — it creates an escaping problem. What if your value contains that character? Now you need escape sequences. And if your value is a shell command that already contains escaping, you end up double-escaping, which is a special kind of misery.

    Whitespace doesn’t have this problem. You rarely need to escape a space. Nobody writes a shell command and worries it contains too many leading spaces. Dmitrii calls whitespace “silent ninjas” — they do structural work without being visible characters anyone would ever need to include literally in a value. YAML uses indentation too, but pairs it with a large surface area of other syntax. CCL uses indentation instead of that other syntax. That’s a meaningful distinction.

    There’s also a deeper mathematical elegance here that I’ll only gesture at: CCL configs compose associatively, and an empty config is a valid identity element, which means the whole thing forms a monoid. If that means something to you, Dmitrii’s post goes much further down that road — it’s one of the more satisfying things I’ve read about config design.

    Minimal syntax in practice

    There’s something else really powerful about CCL. The only characters that are processed in a special way are a newline and an equals sign. And the equals sign is only special when it’s the first one on a line — every other equals is not special.

    This means you can use CCL to embed other languages quite easily. But it also happens to be really useful for apps that need to store shell commands. Escaping is always a pain when you have a shell command that also needs to do escaping in the shell — double escaping is hard.

    CCL solves that because there’s no escaping. You get a string that represents the exact string you need to run in the shell. I make heavy use of this in my app Santa, where every package source is essentially a set of shell commands. It’s very easy to configure in CCL with no weird escaping rules to explain.

    Building with LLMs

    The other reason I found CCL interesting is that it’s been a fun project to explore with LLMs and different programming languages. I have an interest in lots of languages but I’m not fluent in all of them. I can read code and understand what the code does, but I couldn’t necessarily write it from scratch without a lot of references.

    So I had fun creating a library of test cases, then a test harness, then instructions for how to build a test harness in any language. I used that harness to build CCL parser implementations in Rust, Gleam, Go, and TypeScript. Each one was a chance to learn something about the language while also building something genuinely useful.

    CCL isn’t competing with JSON

    Before I go further, I want to be direct about something: I’m not arguing that CCL should replace JSON. That’s not the point, and it’s not a realistic claim.

    JSON’s ubiquity comes from being a data interchange format — something every language can produce and consume, with a spec stable enough that parsers written a decade apart still agree. That’s genuinely valuable, and CCL doesn’t offer it. CCL has no ambitions there, and it shouldn’t. The two formats are solving different problems.

    The argument I’m making is narrower: when you’re choosing a config language and you actually have a choice, CCL deserves serious consideration. Most of the time when we reach for JSON as a config format, we’re not making a deliberate choice — we’re just following the path of least resistance. npm did it, the tools around npm did it, and now the question feels settled before it’s been asked.

    But there are plenty of contexts where the question isn’t settled. You’re building a new tool. You’re designing the config format for an app you own. You’re starting a project that isn’t already embedded in the JSON ecosystem. In those moments, you have a real choice, and the default answer isn’t necessarily the right one.

    CCL will likely never be as popular as JSON, and that’s fine. Popularity follows adoption, and adoption follows ecosystem gravity — JSON has decades of that. What CCL has instead is a set of properties that make it genuinely well-suited to the specific job of human-authored configuration: minimal syntax, no escaping problems, comments as a first-class concept. Those properties don’t help you interchange data between microservices. They do help you write and maintain config files that real people have to read and edit.

    That’s a smaller job than what JSON does. It’s also the job JSON has always been bad at.

    What’s next

    After spending time with this, I wanted to do more than just build parsers for my own use. I’ve created a GitHub organization — CatConfLang — to collect CCL implementations across languages and build out the ecosystem a bit. The reference implementation is Dmitrii’s OCaml version, but there are now parsers in Rust, Go, TypeScript, and Gleam, each developed against a shared test suite so behavior stays consistent.

    I also built a comprehensive test suite that I continue to improve and a website, ccl.tylerbutler.com including LLM prompts for folks who want to explore LLM coding agents on a “real project.”

    If you find CCL interesting, take a look and get in touch! The spec is simple enough that you could implement a parser in an afternoon in whatever language you know best. That’s kind of the point.

  • package.json considered harmful

    Any web developer over the last 15 years or so has encountered JSON. It stands for JavaScript Object Notation, and it is one of the most ubiquitous data formats on the web today. It is, as they say, literally everywhere.

    You’d be forgiven for thinking its ubiquity is evidence of its quality. You would be wrong.

    What JSON is actually for

    Before I make that case, let’s establish what JSON was designed to do, because value judgments need context.

    From Wikipedia:

    JSON grew out of a need for a real-time server-to-browser session communication protocol without using browser plugins such as Flash or Java applets, the dominant methods used in the early 2000s.”

    JSON is JavaScript’s serialization format. It’s how JavaScript takes in-memory data and represents it as text, then converts that text back into an in-memory object. That’s it. That’s the job. It happens that a textual format for data interchange has use beyond JavaScript, and accordingly JSON has spread far beyond JavaScript, and far beyond the web.

    Lots of languages have something like this. Python has pickle — a binary format, notably not portable, and not designed for interchange between languages. Importantly, JSON was not designed for humans. Or at least not primarily for humans. It’s a machine-to-machine format. It exists to transfer data between systems where that data is consumed and created largely by machines.

    Sounds reasonable, no? The problem is that we don’t use it for that.

    To be fair to JSON

    None of this is to say JSON is a bad format. It’s genuinely good at what it was designed for, and it’s worth being clear about that before I make the case against its misuse.

    The security argument for JSON is real. Because JSON can only represent data — strings, numbers, booleans, arrays, objects — there’s nothing to execute. You parse it, you get a data structure, and that’s the end of the story. Compare that to executable config formats like vite.config.ts or Dhall, where loading the config means running code. That’s a meaningful attack surface, and JSON simply doesn’t have it.

    JSON is also genuinely universal. Every language has a JSON parser, and they all agree on the format. That interoperability is not something you get for free — it’s the result of a simple, stable spec that hasn’t changed in decades. When you need two systems written in different languages to exchange data reliably, JSON is still the lowest-friction option most of the time.

    And the tooling is exceptional. JSON Schema, formatters, validators, editor support — the ecosystem around JSON is mature in a way that most config formats can’t match. If you need to validate the structure of a file, or provide IDE autocomplete for it, JSON has a well-worn path for that.

    So JSON is fast to parse, safe to load, universally supported, and well-tooled. The problem isn’t JSON. The problem is that none of those strengths matter much when you’re hand-authoring a config file that you’ll be maintaining for years. For that job, the things JSON is good at are largely irrelevant, and the thing it’s missing — comments — is not.

    Original Sin

    The most prevalent place you’ll find JSON files in the JavaScript ecosystem is package.json — the formal location for package metadata in npm. And “package metadata” might sound like machine-generated data, except almost nothing in package.json is machine-managed. The package name, the version, the keywords, the description, the scripts, the dependencies — all hand-authored. All human-maintained. This is not machine-machine data exchange. And then there’s the sprawl of tool configurations that have followed npm’s example, carrying this cursed seed to far-off lands.

    This is the original sin: npm chose a comment-less serialization format as the de facto config format for an entire ecosystem, before anyone realized what that would cost over time.

    Comments are not optional

    Here’s the core of my argument: a config format without comment support cannot be a good config format. Full stop.

    This sounds like a simple complaint, but follow it through. Configuration is rarely self-explanatory. There are settings that were made for very particular reasons — learned by some engineer long before your time, under constraints you no longer remember. That context needs to live somewhere. It needs to be in the file, near the thing it explains, findable years later when someone is wondering why this dependency is pinned to a version three years old.

    The same rules apply to config that apply to code. If a programming language didn’t support comments, we’d call it untenable. We’d refuse to use it in production. So why do we accept that from a config format? The implicit assumption is that config is somehow simpler or less important than code. It should be simpler — but it is absolutely not less important.

    Let me give you a concrete example:

    You notice a dependency in package.json that looks outdated. Not just outdated… three years old and deprecated! Should it be upgraded? You don’t know. There’s nothing there to tell you. Nothing in the release notes seems like it applies to you. So you decide to try, briefly forgetting the Ferengi maxim, “No good deed goes unpunished.” Hours later, after running CI and chasing down failures, you find a comment on a bug that seems completely unrelated to your issue but nonetheless contains the info you need.

    A gracious fellow engineer has found the key: your setup is one of the rare ones in which upgrading the dependency is a multi-day project. You weep in thanks. Who knew you would owe such a debt to FroyoIsMyFirstLove37? Who can count the number of engineers who came before you and learned this sad lesson — not to mention FroyoIsMyFirstLove37 themselves — but had no obviously correct place to record what they learned? You count yourself lucky that FroyoIsMyFirstLove37 wasn’t, in that moment, lazy.

    Because software engineers, we’re ultimately a lazy bunch, and often the comment doesn’t get added because it’s too much work to figure out where to put it. “Oh well!” is what many engineers will say. Don’t fool yourself into thinking that this is just a cultural problem. When there aren’t obvious places to put things, many engineers will stall out. Even experienced ones. This scenario plays out every day, across repos and projects worldwide, involving countless engineers who likely know better but, like me, are lazy.

    Imagine if instead you saw a comment above that dependency — “pinned at 2.x, API incompatibility in 3.x, see issue #1234” — it probably would have saved all of that effort. And even without the bug number, it’s still useful!

    JSONC and JSON5 are not solutions

    You might be thinking: but what about JSONC? What about JSON5? These formats add comment support to JSON — doesn’t that address the problem?

    No. And I argue they make it worse.

    The challenge is that JSON is now so embedded in the ecosystem that there are countless tools — parsers, validators, formatters, editors — that expect JSON to be JSON. When these tools add “support” for JSONC, they typically do it by stripping the comments before parsing. They’re not actually treating your comments as meaningful data. They’re discarding them and reading standard JSON underneath.

    This creates several downstream problems. Some tools support JSONC for reading but write back standard JSON, silently deleting every comment you’ve written. Hope you committed those comments before running the tool! (I’m looking at you, NX.) Others support JSON but not JSONC, forcing you to maintain comment-less files for compatibility. The fragmentation is real and ongoing.

    But the deeper issue is this: JSONC and JSON5 extend the lifespan of a bad bet. They let the ecosystem keep using an unsuitable format rather than forcing a reckoning. They are patches on a design that was wrong from the start.

    Other ecosystems learned this

    The good news is that newer ecosystems largely got this right. Rust uses Cargo.toml. Gleam uses gleam.toml. Python has pyproject.toml. None of these chose a comment-less format for human-authored configuration.

    I’ll be honest: I have complicated feelings about TOML. It has a reputation for being obvious to Tom, and I am not Tom. I can never quite remember what double brackets mean without checking the docs. But at least it supports comments. At least I can write a note in Cargo.toml explaining why a particular version is pinned, and that note will still be there the next time I open the file, after other tools have already read and modified the file.

    I’d hoped Deno represented a clean break from this history. They built a new JavaScript runtime and had the opportunity to make different choices. And then there are deno.json files. Here we go again.

    The false comfort of executable config

    To be fair, the ecosystem has found one legitimate escape valve: executable config. Many modern tools allow configuration via a JavaScript or TypeScript module — vite.config.ts, eslint.config.js, and so on. These formats support comments, obviously, and offer additional benefits: strong typing, IDE intelligence, composability.

    I actually prefer these when they’re available, precisely because of those conveniences. But they come with a real tradeoff that I think is underappreciated.

    Executable config is executable. That means it can only be loaded in an environment that can run JavaScript. I opened a PR on a project once to add CommonJS config support, and it was rejected for two legitimate reasons: the tool ran in environments where JavaScript might not be available, and as I mentioned, executing arbitrary code during config loading is a real security concern, not just a theoretical one.

    This is also, incidentally, part of why I’m skeptical of PKL and similar “programmable configuration languages.” At the point where your config language is sophisticated enough to need a runtime and an execution model, the honest question is: why aren’t you just using a programming language? The complexity cost is there, but the power isn’t meaningfully greater than just writing code.

    A simple test

    Here’s a heuristic I’ve found useful for thinking about your own JSON usage.

    Ask yourself: could all of your JSON data be replaced by MessagePack tomorrow, and would your workflows notice a significant difference?

    MessagePack is a binary format that’s largely a drop-in replacement for JSON over the wire — more compact, and without the human readability overhead. (Whether it’s actually faster to parse in practice depends heavily on your runtime — V8’s JSON handling is remarkably optimized — but the size savings are real.) If you could make that swap without much disruption, your JSON usage is probably fine. You’re using it for what it was designed for: machine-readable data exchange.

    But if that question fills you with dread — if you’re thinking “but we need to read those files” — then you’re relying on JSON as a human-readable format, and you should use a format fully designed for messy humans.

    There’s also a more obvious tell: if your JSON data contains comment fields. You know the pattern — field_one_comment: "this value is set because...". If you’ve ever done that, or inherited a system that does, that’s a distress signal. That’s a human need (annotation) trying to escape through whatever cracks it can find in a format that didn’t plan for it.

    What to do instead

    If you’re maintaining a JavaScript project and have any flexibility in your config choices, here’s my rough hierarchy:

    Use executable config (TypeScript or ESM modules) when the tool supports it and you can guarantee a JavaScript environment. The developer experience is genuinely excellent.

    Use TOML or YAML for static config that needs to be portable or loaded outside a JavaScript context. Either one supports comments. Neither one is JSON.

    If you’re stuck with JSON, my honest advice is to accept the limitation and compensate elsewhere. Some teams work around it with special comment fields — “_comment” or “field_name_comment” — but these suffer from the same fundamental problem as JSONC: they’re fragile conventions, not first-class features. Any tool that rewrites the file may drop them, reorder them away from the thing they’re annotating, or simply not recognize them. For configuration that genuinely needs annotation, put that documentation in your README or a docs/ file and link to it. It’s not elegant, but it’s more durable than workarounds that depend on every tool in your chain playing along.

    The honest bottom line is that package.json is a blight on the Node ecosystem — not because JSON is a bad format, but because it’s a format that was pressed into service it was never meant for. We made an early choice that calcified into infrastructure, and now we’re all living with the consequences.

    Newer ecosystems know better. But even knowing better, the alternatives I’ve described are compromises: executable config ties you to a runtime, TOML is fine but never quite obvious, YAML trades one set of problems for another. None of them feel like the right answer — they feel like the least-wrong answer. That nagging dissatisfaction is what led me to start looking harder, and eventually to a configuration language I’d never heard of that’s built on an idea so simple it almost seems like it shouldn’t work — but it does. More on that next time.

  • Thanksgiving 2024

    Thanksgiving has been my favorite of the US holidays for some time. I’ve written about it several times over the years.

    When I am away for the holiday, I have used the following Rumi quote as my auto-reply message at work:

    Wear gratitude like a cloak, and it will feed every corner of your life.


    This year, though, has been extremely hard, following several other years that I swore were the hardest. The hits: they keep on coming.

    This year, a different Rumi poem rings true:

    Who makes these changes?
    I shoot an arrow right.
    It lands left.
    I ride after a deer and find myself
    Chased by a hog.
    I plot to get what I want
    And end up in prison.
    I dig pits to trap others
    And fall in.

    I should be suspicious
    Of what I want.

  • Coke Zero II: The Review

    The News

    Last week, my wife shared with me some rather alarming news: Coke Zero is being discontinued. As a man who has an arguably unhealthy attachment to his carbonated beverage of choice, this was panic-worthy.

    You’ll forgive me for not trusting the Coca-Cola Company when they roll out replacement products with “new, improved taste.” After all, this is the same company that brought us New Coke in 1985. Last I heard, that was still used as a cautionary tale in Marketing 101 classes.

    And you’ll also forgive me for not thinking of this as merely a re-branding of the Coke Zero product. The ingredients list may be the same, but they clearly claim to have “a new recipe.” So regardless of what anyone else says, Coke Zero in its current form is gone.

    No big deal, though, right? I mean, announcements like this… they tend to be sensationalist. At the very least, I’ll certainly have several months to prepare myself psychologically.

    Nope:

    The new cans and bottles, which will incorporate more red like regular Coke, will start hitting shelves in August 2017.

    August 2017? Commence freak-out in three… two… one…

    Read More →

  • Comiskey Park

    Well, it’s happened again. They’ve gone and renamed Comiskey Park to something else. And I feel about the same way I did back in 2004, when the last name change happened.

    I lived a few blocks from the park at that time, as opposed to the few thousand I do now, but it still upsets me. Whatever happens, it’ll always be Comiskey Park to me.

View All Articles