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”.
Prerequisites Link to this section
- Style Dictionary v4 or later, configured with an explicit
sourcearray 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.
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.mjson the duplicated token and confirmfilePathnames 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.
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.
Related Link to this section
- Token Aliasing & Reference Resolution — the parent area covering chain depth and convergence
- Resolving Figma Tokens Plugin Export Conflicts — the upstream export problems that produce most broken references
- Style Dictionary vs Theo vs Cobalt — how the three compilers differ in reference handling