How-to › Model, configure and generate

How to reference one value from many places in a config spec#

Name a service once and build its host name, log prefix, and queue name from it in CUE, Dhall, Jsonnet, and Nickel, then see which references survive a refactor.

Audience
Platform team
Level
beginner
Topic
Unify configuration with a spec language
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
CUEFiles in one package share a value with no import lineA reference binds to the nearest field of its name, so a field added inside a block can capture itEvery reference must stay immune to fields added later, without aliases
DhallNo reference may change meaning when someone adds a fieldFields can never refer to each other, so every shared value needs a let, and every other file an importSibling fields should refer to each other without a binding of their own
JsonnetAn override of the name should flow into every value built from itThree reference forms with three scopes, and a self-reference that overflows the stack instead of naming a cycleYou want a cycle reported by name and position
NickelFields of one record refer to each other, and records merge with othersRecords are recursive, so a nested field captures an outer name, and name = name refers to itselfThe 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.

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

  1. 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 prints Cyclic import: ./x.dhall. A config cannot depend on itself more directly than that. ↩︎ Back to text

  2. go-jsonnet does not look for the loop. It counts stack frames, and its vm.go sets the limit, MaxStack, to 500 and cuts the printed trace at 20 lines. The --max-stack flag raises the limit, which moves the error further away without changing it. ↩︎ Back to text

  3. 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 provides include expressions to fix it. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.