The service name billing is typed out three times: in the host name, the log prefix, and the
queue name. A rename catches two of them, so the logs say [payments] while jobs still go to
billing-jobs. Each copy is a valid string on its own, so no schema check can see the mismatch.
What you get
You will end up with a config in CUE, Dhall, Jsonnet, or Nickel that names the service once, and a test that renames it with a one-line edit. This is for you if one value is copied across a config file.
Short answer
In CUE, Dhall, Jsonnet, and Nickel, write the service name once and build the host name, log
prefix, and queue name from it with references, so a rename is a one-line edit. Prefer a let
binding, or a local in Jsonnet, over a field reference. In CUE and Nickel, a field reference
resolves to the nearest field of that name, so a field added later can capture it or turn it
into a cycle.
You will need
Node 22 or later, which runs the sample and its test. Verified 2026-10-21 against Node 22.22.0,
cue 0.17.1, go-jsonnet 0.22.0, dhall-to-json 1.7.12 from the dhall-haskell 1.42.2 release, and
Nickel 1.18.0. npm ci fetches the four evaluators from their GitHub releases into
node_modules/.bin, and checks each against a sha256 pinned in install-tools.mjs. On a
platform with no pinned build, install that tool yourself, and the scripts use it from your
PATH. The sample starts from one service block, the kind a base config in YAML or JSON already
holds.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| CUE | Files in one package share a value with no import line | A reference binds to the nearest field of its name, so a field added inside a block can capture it | Every reference must stay immune to fields added later, without aliases |
| Dhall | No reference may change meaning when someone adds a field | Fields can never refer to each other, so every shared value needs a let, and every other file an import | Sibling fields should refer to each other without a binding of their own |
| Jsonnet | An override of the name should flow into every value built from it | Three reference forms with three scopes, and a self-reference that overflows the stack instead of naming a cycle | You want a cycle reported by name and position |
| Nickel | Fields of one record refer to each other, and records merge with others | Records are recursive, so a nested field captures an outer name, and name = name refers to itself | The config is mostly let-bound values copied into fields |
Dhall is the strictest of the four. Only let-bound names are in scope, so no field can capture a
reference, and reaching another file takes an import. CUE and Nickel let a field refer to another
field, which saves the let and exposes the reference to any field added nearer to it. Jsonnet
offers a lexical local and two late-bound forms, self and $, so its cost is picking the
right one each time.
Write the name once
CUE treats every field as a name that other fields can refer to, and string interpolation builds the derived values from it:
// The service name, written once. Every value built from it refers to it.
name: "billing"
hostname: "\(name).internal.example.com"
log: {
prefix: "[\(name)]"
level: "info"
}
queue: {
topic: "\(name)-jobs"
retries: 3
}
npx --no cue export service.cue
{
"name": "billing",
"hostname": "billing.internal.example.com",
"log": {
"prefix": "[billing]",
"level": "info"
},
"queue": {
"topic": "billing-jobs",
"retries": 3
}
}
npx --no runs the cue that npm ci placed in node_modules/.bin, and refuses to download a
registry package of the same name. With cue on your PATH, run cue export service.cue. The
service name sits on one line, and three values follow it.
Build the same block in the other three languages
Each language reaches the name through its own scoping rule. Dhall puts only let-bound names in scope, and a record’s fields are never visible inside it:
-- The service name, written once, as a let binding. Record fields are never in
-- scope in Dhall, so a value can only refer to a name that a let has bound.
let name = "billing"
in { name
, hostname = "${name}.internal.example.com"
, log = { prefix = "[${name}]", level = "info" }
, queue = { topic = "${name}-jobs", retries = 3 }
}
Jsonnet reads a sibling field through self and the outermost object through $, as
its tutorial defines them:
// The service name, written once. self is the object being defined, so a
// sibling field reads it as self.name; inside a nested object, self is that
// object, and $ reaches the outermost one.
{
name: 'billing',
hostname: self.name + '.internal.example.com',
log: {
prefix: '[' + $.name + ']',
level: 'info',
},
queue: {
topic: $.name + '-jobs',
retries: 3,
},
}
Nickel records are recursive, so any field can name another field from any depth:
# The service name, written once. Record fields are recursive, so any field can
# refer to another by name, from any depth inside the record.
{
name = "billing",
hostname = "%{name}.internal.example.com",
log = {
prefix = "[%{name}]",
level = "info",
},
queue = {
topic = "%{name}-jobs",
retries = 3,
},
}
Evaluate all four and compare the three derived values:
node evaluate.mjs
command hostname log.prefix queue.topic
cue export service.cue billing.internal.example.com [billing] billing-jobs
dhall-to-json --file service.dhall billing.internal.example.com [billing] billing-jobs
jsonnet service.jsonnet billing.internal.example.com [billing] billing-jobs
nickel export service.ncl billing.internal.example.com [billing] billing-jobs
The four evaluators print the same host name, log prefix, and queue name, each from one written copy of the name.
Move the name, then add a nearer field
A rename is the gentlest edit a reference sees. Two other edits test it harder. The first moves
name into a service block and leaves the references alone. The second adds a log sink inside
log whose field is also called name, and each shadow file writes the reference every way
its language allows:
// A log sink arrives inside log, under a field that is also called name.
let service = "billing"
name: service
log: {
name: "stdout"
via_field: "[\(name)]"
via_let: "[\(service)]"
}
node refactor.mjs
name moved into a service block, references unchanged
cue export moved.cue exit 1 hostname: reference "name" not found:
dhall-to-json --file moved.dhall exit 1 Error: Unbound variable: name
jsonnet moved.jsonnet exit 1 RUNTIME ERROR: Field does not exist: name
nickel export moved.ncl exit 1 error: unbound identifier `name`
log.name = "stdout" added beside the references
cue log.via_field [stdout]
cue log.via_let [billing]
dhall log.via_let [billing]
jsonnet log.via_local [billing]
jsonnet log.via_root [billing]
jsonnet log.via_self [stdout]
nickel log.via_field [stdout]
nickel log.via_let [billing]
Moving the name breaks all four loudly, because none of them finds it where the references look.
The nearer field is the silent case. CUE resolves a reference to
the field in the nearest enclosing scope, so
log.via_field reads the sink’s name. Jsonnet’s self and Nickel’s field reference do the same.
A let binding, Jsonnet’s local, and Jsonnet’s $ are untouched, and so is Dhall, where fields
are never in scope.
The repair in CUE is a let or an alias.
Either one is a name that no field added later can capture. In Nickel, bind the value under a
name no field uses. The same scope rule lets a CUE reference reach across files. A name with no
match in its own file can match a top-level field in another file of the same package, with no
import line.
Dhall, Jsonnet, and Nickel reach another file only through an explicit import.
Check it worked
The test renames the service in a copy of each config by changing the one line that holds
billing, then evaluates the copy and checks every derived value:
for (const lang of LANGS) {
const file = `service.${EXTENSION[lang]}`
test(`${lang}: renaming the service is a one-line edit`, () => {
const src = readFileSync(join(HERE, file), 'utf8')
assert.equal(src.split('\n').filter((l) => l.includes('billing')).length, 1, 'the name is on one line')
const dir = mkdtempSync(join(tmpdir(), 'rename-'))
try {
writeFileSync(join(dir, file), src.replace('billing', 'payments'))
const c = evaluate(lang, file, { cwd: dir })
assert.equal(c.name, 'payments')
assert.equal(c.hostname, 'payments.internal.example.com')
assert.equal(c.log.prefix, '[payments]')
assert.equal(c.queue.topic, 'payments-jobs')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
}
node --test rename.test.mjs
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 339.373028
Four tests cover the renames. Three more pin the capture that refactor.mjs shows, the loud
failure when the name moves, and the cycles below. Each checks the evaluator’s own message, so a
change in an evaluator’s behavior fails here first.
When it goes wrong
A value that depends on itself is the failure each evaluator reports in its own way. The queue
gets a name of its own, built from the service name, but inside queue the identifier name
means the queue’s own field. Dhall cannot make that mistake, so its version moves the queue to a
second file that reads the name back from the first:
node cycle.mjs
cue export cycle.cue (exit 1, 5 lines)
queue.name: invalid interpolation: cycle with field: name:
./cycle.cue:5:11
./cycle.cue:5:14
queue.name: cycle with field: name:
./cycle.cue:5:14
dhall-to-json --file cycle-service.dhall (exit 1, 5 lines)
↳ ./cycle-queue.dhall
↳ ./cycle-service.dhall
Cyclic import: ./cycle-queue.dhall
3│ ./cycle-queue.dhall
cycle-service.dhall:3:13
jsonnet cycle.jsonnet (exit 1, 22 lines)
RUNTIME ERROR: max stack frames exceeded.
cycle.jsonnet:6:11-20 object <anonymous>
cycle.jsonnet:6:11-20 object <anonymous>
cycle.jsonnet:6:11-20 object <anonymous>
cycle.jsonnet:6:11-20 object <anonymous>
cycle.jsonnet:6:11-20 object <anonymous>
...
nickel export cycle.ncl (exit 1, 5 lines)
error: infinite recursion
┌─ cycle.ncl:6:12
│
6 │ name = "%{name}-jobs",
│ ^^^^^^^^^^^^^^ recursive reference
nickel export cycle-let.ncl (exit 1, 5 lines)
error: infinite recursion
┌─ cycle-let.ncl:5:10
│
5 │ name = name,
│ ^^^^ recursive reference
CUE names the cycle and both positions: queue.name refers to itself. Rename the inner field, or
reach the outer one through a let or an alias. A bare copy, name: name, fails differently,
because CUE’s specification treats a field that refers
to itself as _. Its export reports queue.name: incomplete value _ without naming a cycle.
Dhall refuses the import before it evaluates anything, and prints the chain of imports that closed the loop.1 Make the queue file a function of the name, and apply it in the service file.
Jsonnet does not detect the cycle. It overflows its stack and prints the same frame until the
trace is cut off.2 The frame names the line to fix: use $.name or a local there instead of
self.name.
Nickel reports infinite recursion at the reference. Its second case is the common form,
let name = ... in { name = name }, where the field shadows the binding it was meant to copy.3
Write include name instead, which takes the value from outside the record.
When not to do this
Do not derive a value that only looks like the same value. If consumers or a dead-letter queue are bound to the queue name, it has to outlive a rename. Write it out literally.
Do not use a field reference where a let binding works. A field reference saves one line and stays exposed to every field added nearer to it.
Do not adopt a configuration language to remove three copies of one string. If the config is otherwise flat YAML, the program that reads it can build the host name from the name, and a test can check that the three agree.
Do not split a Dhall config into files that import each other. Dhall rejects the cycle outright, so pass values down as function arguments instead.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0, cue 0.17.1, go-jsonnet 0.22.0, dhall-to-json 1.7.12,
and Nickel 1.18.0, the release binaries npm ci installs. Every output block is what the command
preceding it printed. The scripts strip the sample directory from file paths and the color codes
from the messages Dhall prints, and print the rest as each evaluator wrote it.
Footnotes
-
The Dhall language tour sets this as an exercise: what happens to an expression that imports itself. Its solution writes the smallest cycle there is, a file whose whole content is
./x.dhall, and printsCyclic import: ./x.dhall. A config cannot depend on itself more directly than that. ↩︎ Back to text -
go-jsonnetdoes not look for the loop. It counts stack frames, and itsvm.gosets the limit,MaxStack, to 500 and cuts the printed trace at 20 lines. The--max-stackflag raises the limit, which moves the error further away without changing it. ↩︎ Back to text -
Nickel’s manual meets the same trap with a function parameter rather than a let, and gets the same
infinite recursion. It calls the usual work-around, renaming the outer name, unsatisfying, and providesincludeexpressions to fix it. ↩︎ Back to text