Resolving Token Alias Chains Without Circular References
Part of Token Aliasing & Reference Resolution. This guide builds the resolver: a hundred lines that turn a token file with references into resolved values, and that fail with a readable message instead of a stack overflow when the references form a loop.
Prerequisites Link to this section
- A token source in DTCG shape where references are written as
{group.token}strings. - Node 20 or later; the code below uses only built-in modules.
- A decision on the maximum chain depth for the system — see the parent area for how to pick one.
- A CI job that can run a script and fail on a non-zero exit code.
Step-by-Step Implementation Link to this section
Step 1 — Flatten the token tree into a lookup map Link to this section
Intent: references address tokens by dotted path, so the resolver needs a flat map keyed by that path before it can follow anything.
// scripts/flatten.mjs
export function flatten(node, path = [], out = new Map()) {
for (const [key, value] of Object.entries(node)) {
if (value && typeof value === 'object' && '$value' in value) {
out.set([...path, key].join('.'), value);
} else if (value && typeof value === 'object') {
flatten(value, [...path, key], out);
}
}
return out;
}
Why this works: the DTCG shape distinguishes a token from a group by the presence of $value, so the recursion needs no schema and no configuration. Using a Map rather than a plain object avoids a class of bug where a token path collides with an inherited property name such as constructor.
Step 2 — Walk the chain, carrying the path Link to this section
Intent: resolve one token to its literal value, and detect a cycle by checking whether the current name is already on the path.
// scripts/resolve.mjs
const REFERENCE = /^\{([^}]+)\}$/;
export class ReferenceError extends Error {
constructor(message, path) { super(message); this.path = path; }
}
export function resolve(name, tokens, path = []) {
if (path.includes(name)) {
throw new ReferenceError(
`Circular reference: ${[...path, name].join(' → ')}`, [...path, name]);
}
const token = tokens.get(name);
if (!token) {
const from = path.at(-1);
throw new ReferenceError(
`Unresolved reference "${name}"` + (from ? ` referenced by "${from}"` : ''), path);
}
const match = REFERENCE.exec(String(token.$value).trim());
if (!match) return { value: token.$value, chain: [...path, name] };
return resolve(match[1], tokens, [...path, name]);
}
Why this works: path.includes(name) is an O(n) check on a list that is at most a handful of entries deep, so the theoretical inefficiency never materialises. In exchange, the path array is available at the moment of failure, which is what allows the message to print the full loop rather than just the token where it was noticed.
Step 3 — Enforce a depth limit Link to this section
Intent: a chain that is not circular can still be too long to reason about. Fail above the number of architectural tiers.
const MAX_DEPTH = 4;
export function resolveWithLimit(name, tokens) {
const result = resolve(name, tokens);
const hops = result.chain.length - 1;
if (hops > MAX_DEPTH) {
throw new ReferenceError(
`Chain depth ${hops} exceeds the limit of ${MAX_DEPTH}:\n ` +
result.chain.join('\n → '), result.chain);
}
return result;
}
Why this works: printing the chain vertically rather than inline matters more than it sounds. A five-hop chain printed on one line wraps in a CI log and becomes unreadable; printed as a list, the offending same-tier hop is usually obvious at a glance.
Step 4 — Resolve every token and collect all failures Link to this section
Intent: one run should report every problem, not the first one.
// scripts/check-references.mjs
import { readFileSync } from 'node:fs';
import { flatten } from './flatten.mjs';
import { resolveWithLimit } from './resolve.mjs';
const tokens = flatten(JSON.parse(readFileSync('tokens/tokens.json', 'utf8')));
const failures = [];
const resolved = new Map();
for (const name of tokens.keys()) {
try {
resolved.set(name, resolveWithLimit(name, tokens));
} catch (error) {
failures.push(error);
}
}
if (failures.length) {
console.error(`\n${failures.length} reference problem(s):\n`);
for (const error of failures) console.error(` ✗ ${error.message}\n`);
process.exit(1);
}
console.log(`resolved ${resolved.size} token(s), max depth ` +
Math.max(...[...resolved.values()].map((r) => r.chain.length - 1)));
Why this works: collecting rather than throwing means a contributor who introduced three broken references fixes all three in one edit. Printing the maximum observed depth on success gives a free early warning: when that number creeps from two to four over a few releases, the graph is getting harder to reason about before any gate has failed.
Step 5 — Wire it into CI ahead of the compiler Link to this section
Intent: run the check before the compiler, so a cycle produces this message rather than the compiler’s stack trace.
# .github/workflows/tokens.yml
name: Tokens
on: [pull_request]
jobs:
references:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- name: Check token references
run: node scripts/check-references.mjs
- name: Build tokens
run: npm run build:tokens
Why this works: most compilers detect cycles too, but their error messages are written for compiler maintainers rather than for the designer who added the reference. Running a purpose-built check first means the readable message is the one the contributor sees.
Verification Link to this section
Prove the checker fails before trusting it to pass. Three fixtures, committed to the repository, cover the cases:
// tests/references.test.mjs
import { test } from 'node:test';
import assert from 'node:assert';
import { flatten } from '../scripts/flatten.mjs';
import { resolve, resolveWithLimit } from '../scripts/resolve.mjs';
const build = (obj) => flatten(obj);
test('detects a direct cycle and names both tokens', () => {
const tokens = build({ a: { $value: '{b}' }, b: { $value: '{a}' } });
assert.throws(() => resolve('a', tokens), /a → b → a/);
});
test('detects an unresolved reference and names the referrer', () => {
const tokens = build({ a: { $value: '{missing}' } });
assert.throws(() => resolve('a', tokens), /referenced by "a"/);
});
test('rejects a chain deeper than the limit', () => {
const tokens = build({
a: { $value: '{b}' }, b: { $value: '{c}' }, c: { $value: '{d}' },
d: { $value: '{e}' }, e: { $value: '#2563eb' },
});
assert.throws(() => resolveWithLimit('a', tokens), /exceeds the limit/);
});
test('resolves a valid chain to its literal', () => {
const tokens = build({ a: { $value: '{b}' }, b: { $value: '#2563eb' } });
assert.equal(resolve('a', tokens).value, '#2563eb');
});
The fourth test matters as much as the first three. A resolver that throws on everything passes all the failure tests and is useless, and that mistake is easy to make when tightening the cycle check.
Troubleshooting Link to this section
| Symptom | Likely cause | Fix |
|---|---|---|
RangeError: Maximum call stack size exceeded |
A cycle reached the compiler because the check ran after it | Move the reference check ahead of the build step in CI |
| A cycle is reported but both tokens look correct | The loop is longer than two hops; the message lists every participant | Read the full path — the offending edge is usually the third or fourth |
| The check passes locally and fails in CI | Different token files are present, typically a brand file only fetched in CI | Run the check against the same merged input the build uses |
| An unresolved reference names a token that exists | A path typo in a group name, so the flattened key differs | Print the nearest keys by edit distance in the error message |
| The depth limit fires on a legitimate chain | A same-tier hop that should be a derived value | Convert the hop to a build-time derivation rather than raising the limit |
Why Cycles Are Not Caught by a Schema Link to this section
It is reasonable to ask why the JSON Schema gate does not catch this, given that it validates the same files. The answer is structural: a schema describes the shape of a document, and a cycle is a property of the graph the document describes, not of the document itself.
Every token in a cycle is individually valid. {"$value": "{color.action}", "$type": "color"} is a
perfectly well-formed colour token whether or not color.action eventually points back at it. A
schema can require that the value matches the reference pattern, that the type is one of the
permitted values, and that no unexpected keys are present — and a file full of cycles satisfies all
three.
This is a useful general principle when deciding where a check belongs. Schemas answer questions about individual nodes; graph walks answer questions about relationships between them. Contrast checking, convergence checking, cycle detection and depth limiting are all in the second category, which is why they live in a script that runs after parsing rather than in the schema itself.
The practical consequence is ordering. Run the schema gate first, because a malformed file makes the graph walk fail in confusing ways, and run the graph checks second, on input the schema has already declared well formed.
Migration Note Link to this section
Introducing this check into an existing token set usually surfaces two or three long chains and, occasionally, one genuine cycle that has been silently truncated by the compiler for months.
Run it in reporting mode first: log the failures and exit zero. That gives the real distribution of chain depths, which is the number to set the limit from — pick the smallest limit that passes today’s tokens, rather than an aspirational one, and tighten it later.
If a genuine cycle is found, resist the temptation to break it by flattening one side to a literal. That fixes the build and loses the relationship, which will be re-established by hand at the next brand change. The correct fix is almost always to identify which of the two tokens is the source of truth and make the other one derive from it.
Related Link to this section
- Token Aliasing & Reference Resolution — the parent area covering depth limits and convergence
- Debugging Unresolved Token References in Style Dictionary — reading the compiler’s own diagnostics when the check is not enough
- Validating Design Tokens Against JSON Schema in CI — the gate that runs before this one and guarantees the reference syntax parses