# 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.

Source: https://voxgig.com/howto/reference-one-value-across-a-config

- Audience: platform-team
- Level: beginner
- Verified: 2026-10-21
- Published: 2026-10-20

## 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](https://cuelang.org/docs/tour/references/scopes/) | 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](https://docs.dhall-lang.org/tutorials/Language-Tour.html#let-expressions) | 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](https://jsonnet.org/learning/tutorial.html) | 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](https://nickel-lang.org/user-manual/syntax/) | 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:

```cue title="service.cue"
// 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
}
```

```bash
npx --no cue export service.cue
```

```text output
{
    "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:

```text title="service.dhall"
-- 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](https://jsonnet.org/learning/tutorial.html#references) defines them:

```jsonnet title="service.jsonnet"
// 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:

```text title="service.ncl"
# 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:

```bash
node evaluate.mjs
```

```text output
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:

```cue title="shadow.cue"
// 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)]"
}
```

```bash
node refactor.mjs
```

```text output
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](https://cuelang.org/docs/tour/references/scopes/), 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](https://cuelang.org/docs/tour/references/aliases/).
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:

```js title="rename.test.mjs"
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 })
    }
  })
}
```

```bash
node --test rename.test.mjs
```

```text output
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:

```bash
node cycle.mjs
```

```text output
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](https://cuelang.org/docs/reference/spec/) 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

- [Include one config file from another](/howto/include-one-config-file-from-another)

- [Explain why a unified config has a given value](/howto/explain-why-a-unified-config-value)

## 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.

[^1]: The Dhall [language tour](https://docs.dhall-lang.org/tutorials/Language-Tour.html#file-imports)
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.

[^2]: `go-jsonnet` does not look for the loop. It counts stack frames, and
[its `vm.go`](https://github.com/google/go-jsonnet/blob/v0.22.0/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.

[^3]: Nickel's [manual](https://nickel-lang.org/user-manual/syntax/#include-expressions) 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.