Debugging Unresolved Token References in Style Dictionary

Part of Token Aliasing & Reference Resolution. An unresolved reference rarely announces itself. This guide covers the three shapes the failure takes, how to tell them apart in under a minute, and the script that answers “where did this token actually come from”.

Three observable outcomes of an unresolved reference Three panels show what a failed reference looks like: a build error, a literal brace string in the compiled CSS, and a silently correct-looking value that came from a different source than intended. Build error "Reference doesn't exist" names the token and path best case the failure is where the mistake was made requires strict mode Literal brace in CSS --btn-bg: {color.action} CSS parses it as invalid visible if you look the property is dropped, the component renders bare grep the output for { Wrong source, right shape a same-named token in another file resolved instead worst case valid CSS, plausible colour, nothing to grep for only a path trace finds it
The three outcomes need three different diagnostics. Only the first is a failure the build reports on its own.

Prerequisites Link to this section

  • Style Dictionary v4 or later, configured with an explicit source array rather than a bare glob.
  • Node 20 or later for the diagnostic script.
  • Access to the same token inputs CI uses — including any brand or theme files fetched at build time.
  • A build that writes a manifest, or the willingness to add one; several checks below depend on it.

Step-by-Step Implementation Link to this section

Step 1 — Turn the silent failure into a loud one Link to this section

Intent: by default, a compiler may emit an unresolvable reference as a literal string. Configure it to stop instead.

// sd.config.mjs
import StyleDictionary from 'style-dictionary';

const sd = new StyleDictionary({
  source: [
    'tokens/primitives/*.json',
    'tokens/semantic/*.json',
    'tokens/brands/*.json',
  ],
  log: {
    warnings: 'error',        // treat warnings as failures
    errors: { brokenReferences: 'throw' },
    verbosity: 'verbose',     // print the full path, not a summary
  },
  platforms: { /* … */ },
});

await sd.buildAllPlatforms();

Why this works: brokenReferences: 'throw' converts the most common silent failure into a build error naming the reference and the token that made it. verbosity: 'verbose' matters as much: the default summary tells you how many references broke, not which, and the count alone is not actionable.

Step 2 — Grep the output for the tell Link to this section

Intent: if the build already ran without strict mode, the compiled artefact carries the evidence.

# A literal brace in compiled CSS is always a failed reference.
grep -n '{[a-z]' dist/variables.css

# The same check as a CI gate.
if grep -q '{[a-z]' dist/variables.css; then
  echo "Unresolved token reference(s) in compiled CSS:" >&2
  grep -n '{[a-z]' dist/variables.css >&2
  exit 1
fi

Why this works: valid CSS custom property values never contain a bare { followed by a letter — the character only appears in selectors and at-rule blocks, which sit at the start of a line in generated output. The check takes milliseconds and catches every instance of the second failure shape, including ones introduced by a plugin after the compiler ran.

Step 3 — Print the resolution path for one token Link to this section

Intent: answer “where did this value come from” directly, rather than by reading configuration and inferring.

// scripts/trace.mjs — node scripts/trace.mjs color.action.primary
import StyleDictionary from 'style-dictionary';
import config from '../sd.config.mjs';

const target = process.argv[2];
const sd = new StyleDictionary(config);
await sd.hasInitialized;

const flat = sd.tokens;
const walk = (path) => path.split('.').reduce((node, key) => node?.[key], flat);

const token = walk(target);
if (!token) {
  console.error(`No token at path "${target}".`);
  console.error('Nearest paths:');
  // Cheap suggestion: same last segment, any parent.
  const last = target.split('.').at(-1);
  const candidates = [];
  const collect = (node, path = []) => {
    for (const [k, v] of Object.entries(node ?? {})) {
      if (v && typeof v === 'object' && '$value' in v) {
        if (k === last) candidates.push([...path, k].join('.'));
      } else if (v && typeof v === 'object') collect(v, [...path, k]);
    }
  };
  collect(flat);
  candidates.slice(0, 5).forEach((c) => console.error(`  ${c}`));
  process.exit(1);
}

console.log(`path:      ${target}`);
console.log(`file:      ${token.filePath}`);
console.log(`authored:  ${JSON.stringify(token.original?.$value ?? token.$value)}`);
console.log(`resolved:  ${JSON.stringify(token.$value)}`);
console.log(`type:      ${token.$type}`);

Why this works: filePath is the field that answers the hardest version of this question. When two files declare the same path, the compiler resolves one of them and the other is invisible — and no amount of reading the token JSON reveals which won. Printing the file the winning definition came from turns a puzzling value into an obvious file-ordering problem.

Why the same reference resolves differently in CI A local build includes three token files while CI includes a fourth brand file, changing which definition of a duplicated path wins and therefore what the reference resolves to. Local build — 3 sources primitives/color.json semantic/color.json brands/default.json --color-action resolves to blue-600 CI build — 4 sources primitives/color.json semantic/color.json brands/acme.json — fetched in CI only --color-action resolves to acme-teal
"It works locally" is almost always this: the two environments do not have the same source list, so a duplicated path resolves to a different definition.

Step 4 — Detect duplicate definitions explicitly Link to this section

Intent: stop relying on discovering the duplicate through its symptoms.

// scripts/check-duplicates.mjs
import { globSync } from 'node:fs';
import { readFileSync } from 'node:fs';

const seen = new Map();     // path → [files]
for (const file of globSync('tokens/**/*.json')) {
  const walk = (node, path = []) => {
    for (const [key, value] of Object.entries(node)) {
      if (value && typeof value === 'object' && '$value' in value) {
        const dotted = [...path, key].join('.');
        seen.set(dotted, [...(seen.get(dotted) ?? []), file]);
      } else if (value && typeof value === 'object') {
        walk(value, [...path, key]);
      }
    }
  };
  walk(JSON.parse(readFileSync(file, 'utf8')));
}

const duplicates = [...seen].filter(([, files]) => files.length > 1);
for (const [path, files] of duplicates) {
  console.error(`Duplicate definition of "${path}":`);
  files.forEach((f) => console.error(`  ${f}`));
}
process.exit(duplicates.length ? 1 : 0);

Why this works: duplication is legal and sometimes intentional — a brand file overriding a default is exactly this pattern. The check therefore belongs in reporting mode for most systems, and in blocking mode for the directories where duplication is never intended, such as primitives/.

Step 5 — Pin the source order Link to this section

Intent: where duplication is intentional, make the winner deterministic.

// Explicit and ordered, never a bare glob.
source: [
  'tokens/primitives/*.json',   // lowest precedence
  'tokens/semantic/*.json',
  'tokens/brands/default.json',
  `tokens/brands/${process.env.BRAND ?? 'default'}.json`,   // highest
],

Why this works: a glob such as tokens/**/*.json resolves in filesystem order, which differs between operating systems and can change when a file is renamed. Enumerating the layers explicitly makes precedence a property of the configuration rather than of the machine, which is the underlying cause of most “works locally” reports.

Verification Link to this section

Confirm the diagnosis pipeline works end to end by introducing each failure deliberately in a scratch branch:

  • Rename a referenced token and confirm the build now throws with the reference name in the message.
  • Disable strict mode, rebuild, and confirm the grep catches the literal brace in the output.
  • Add a duplicate definition in a second file and confirm the duplicate check reports both paths.
  • Run node scripts/trace.mjs on the duplicated token and confirm filePath names the winning file.

The fourth check is the one worth keeping as a habit. When a colour looks wrong, tracing the token takes ten seconds and answers whether the problem is in the value, the reference, or the file precedence — which are three different investigations.

Diagnosis order for a token with an unexpected value Four ordered checks: confirm the token exists, compare authored against resolved value, check the winning file path, and check for duplicate definitions of the same path. 1 · exists? trace prints the path or suggests neighbours 2 · authored? original vs resolved value side by side 3 · which file? filePath names the winning definition 4 · duplicated? check-duplicates lists every file Each step is cheap and eliminates a whole class of cause, so working in order takes less time than guessing.
Four checks, in order, resolve every reference problem this page describes. Skipping to step four is the usual reason a debugging session takes an afternoon.

Troubleshooting Link to this section

Symptom Likely cause Fix
Reference doesn't exist on a token that is clearly present The referenced file is not in the source array Add the file explicitly; a glob may not match a newly added directory
Compiled CSS contains {color.action} Strict mode is off and the reference failed Enable brokenReferences: 'throw', then fix the reference the error names
The value is valid but wrong A duplicate definition later in the source order won Run the duplicate check and pin the source order
The build passes locally and fails in CI CI fetches an extra brand or theme file Reproduce with the same BRAND environment variable and source list
A reference resolves to undefined in the manifest The token was filtered out of the platform but is still referenced Filters apply after resolution — filter the referrer too, or keep the referenced token

Reading the Error Message Precisely Link to this section

Compiler messages for reference failures use a small vocabulary, and each phrase points at a different thing. Being precise about which one appeared saves most of the investigation.

“Reference doesn’t exist” means the path was parsed and looked up, and nothing was found at it. The reference syntax is fine; the target is missing from the merged token set. Look at the source array before looking at the token file.

“Circular definition” means the path exists but resolving it returns to a token already being resolved. The compiler usually names only two participants even when the loop is longer, which is why the dedicated cycle check with its ordered path is worth running first.

A message naming a property rather than a token — “Cannot read properties of undefined” — usually means the token exists but is shaped unexpectedly: a group where a token was expected, or a value that is an object rather than a string. This is the signature of a composite type such as a shadow or a typography set being referenced as if it were a scalar.

Finally, a message that names a platform rather than a token points at a filter, not a reference. A token filtered out of one platform’s output while still being referenced by a token that survived the filter produces exactly this, and the fix is in the filter predicate rather than in the token file.

Migration Note Link to this section

If a codebase has been building without strict references for a while, turning it on will produce a list rather than a single error. That list is worth reading in full before fixing anything, because the entries usually cluster: one renamed group accounts for a dozen broken references, and one missing source file accounts for the rest.

Fix by cluster, not by entry. Restoring a missing file to the source array resolves everything in one commit; chasing individual references leads to reintroducing literals in place of aliases, which loses exactly the relationships the token system exists to record.