Adding Types to JSON with Dhall

A few months ago I wrote a post asking whether there’s something better than JSON. Two configuration languages that sit above JSON kept coming up: CUE and Dhall. Both give you the things JSON lacks when you author config by hand, and both compile down to plain JSON, YAML, or whatever your services actually read. I spent more time with CUE back then and never gave Dhall a real look. This post is me going back for that second look, because the one feature I kept wanting was a type system over my config.

JSON is the universal language of API payloads and config files, and I don’t want that to change. But as a format for authoring configuration by hand, it’s rough:

  • No comments.
  • No variables or functions, so you copy-paste the same block ten times.
  • No type system, so "8080" and 8080 look equally valid.
  • No imports, which is how you end up with a 2,000-line monolith nobody wants to touch.

The usual escape hatch is a templating engine like Jinja or Helm, or a real programming language like Python or TypeScript that spits out JSON. That works, but you’ve traded one problem for a scarier one: your config generator is now Turing-complete. It can crash, hang in an infinite loop, or reach out and read some local environment variable, and it’ll do it at 2 AM when the pipeline runs.

This is where Dhall comes in.

What is Dhall?

The short version: Dhall is JSON plus types, plus functions, plus imports. It’s a programmable, strongly-typed configuration language.

The part I actually care about is what it doesn’t have. Dhall is not Turing-complete. No arbitrary recursion, no side effects. Every Dhall program is guaranteed to terminate. You get the abstraction power of a functional language like Haskell or Elm, with the guarantee that it will never hang your build. That’s a different trade than “just write a Python script.”

The problem, in JSON

Here’s a normal config.json for a microservice:

{
  "serviceName": "payment-api",
  "port": 8080,
  "environment": "production",
  "database": {
    "host": "db.internal.net",
    "maxConnections": 50
  }
}

Three ways can be a problem in production: someone writes "port": "8080" and the service won’t boot, someone typos "prodution" and it silently runs in debug mode, or someone forgets maxConnections entirely and you get a null blowup at runtime. Nothing catches any of it until it’s live.

The same thing, typed

In Dhall you define the shape up front. Enums, record types, default values:

-- schema.dhall
let Environment = < Local | Staging | Production >

let Database =
      { Type = { host : Text, maxConnections : Natural }
      , default = { maxConnections = 20 }
      }

let Config =
      { Type =
          { serviceName : Text
          , port : Natural
          , environment : Environment
          , database : Database.Type
          }
      , default =
          { port = 8080, environment = Environment.Local }
      }

in  { Environment, Database, Config }

Now you author against that schema, and you get defaults and composition for free:

-- config.dhall
let Schema = ./schema.dhall

let myConfig : Schema.Config.Type =
      Schema.Config.default
      // { serviceName = "payment-api"
         , environment = Schema.Environment.Production
         , database = Schema.Database.default // { host = "db.internal.net", maxConnections = 50 }
         }

in  myConfig

Misspell Production, or pass "8080" as a string, and Dhall throws a type error before a single line of JSON is generated. Hopefully the benfit is now clear; adding a type safety layer to your config files.

Compiling down to JSON

You don’t ship Dhall to your services. You ship the JSON they already understand:

brew install dhall-json
dhall-to-json --file config.dhall

Out comes clean, boring, standard JSON. Your services never know Dhall was involved. The part that I like is the safety lives at authoring time, and the runtime artifact stays dumb.

Two features worth knowing about

Hermetic imports with hash pinning. Dhall can import from a URL, so shared utilities live in one place instead of being copy-pasted across five repos. To keep someone from swapping the file out from under you, you pin the import to a SHA-256 hash of its normalized form:

let Prelude =
      https://prelude.dhall-lang.org/v22.0.0/package.dhall
        sha256:10db4c919c25e4d262db3ed0d1d6120da3e3906673f00e3012c1d14e1963976a

If the remote content changes, the hash won’t match and the build fails. The hash above is just an example, and each Prelude version has its own, so don’t copy it by hand. dhall freeze --inplace config.dhall computes the correct hashes for whatever you’ve imported and pins them automatically.

Exhaustive matching with merge. When you map a union type to output, Dhall makes you handle every variant:

let getLogPrefix = \(env : Environment) ->
      merge
        { Local = "[DEV] ", Staging = "[STAGE] ", Production = "[PROD] " }
        env

Add a QA variant later, and every merge block that touched Environment fails to compile until you deal with it. No forgotten switch case slipping into production. The compiler keeps a running list of everything you now owe it.

Is it worth it?

Raw JSON Dhall
Type safety None, fails at runtime Static, at compile time
Comments & logic No Yes
Termination N/A Guaranteed
Dependency pinning No SHA-256
Output Consumed directly Compiles to JSON/YAML/TOML

For a two-key config file, it doesn’t make sense, but once you’re staring down Kubernetes manifests, a pile of near-identical microservice configs, or anything where a typo takes down a service, the calculus changes. You keep clean static JSON as the thing your services actually read, and you move all the ways-to-get-it-wrong to a place where a compiler catches them first.


Sources


I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].

DevOps Programming Json Configuration Dhall