Token Aliasing & Reference Resolution

Part of Design System Token Fundamentals & Naming Conventions. Aliasing is what lets one value serve many names, and reference resolution is the machinery that turns those names back into values. This area covers how that machinery behaves, where it breaks, and how to keep chains short enough to reason about.

A four-link alias chain resolving to a literal value A component alias points at a semantic role, which points at a brand alias, which points at a primitive, which holds the literal colour value. Each hop is labelled with the tier it belongs to. --btn-bg component alias --color-action semantic role theme-swapped --brand-accent brand alias per tenant #2563eb primitive literal Each hop is a place the chain can break A rename at any link leaves the ones downstream resolving to nothing. A cycle anywhere makes the whole file unresolvable. Depth is the variable you control: three hops is comprehensible, six is a debugging session.
A reference chain trades directness for flexibility. Every hop buys a place to intervene and costs a place things can go wrong.

Problem Framing Link to this section

A brand ships a new accent colour. The token file is edited in one place, the build succeeds, and half the interface changes — the other half does not. Investigation shows two chains that were supposed to converge on the same primitive: one goes through the semantic role, and the other was wired directly to a different primitive during a rush eighteen months ago. Both are valid tokens, both compile, and nothing in the build ever compared them.

This is the characteristic failure of a referencing system. References make change cheap when they are consistent and make inconsistency invisible when they are not. Neither a schema check nor a lint rule catches it, because both chains are individually well formed; the defect only exists in the relationship between them.

The tools that do catch it operate on the resolved graph rather than on the source: cycle detection, depth limits, convergence checks, and a resolved-value diff between builds. Each is a few dozen lines, and together they turn the alias graph from something the team hopes is consistent into something the build knows is.

Three-Tier Architectural Trade-offs Link to this section

  • Shallow chains vs expressive layering. Two hops — semantic to primitive — is trivially debuggable and leaves no room for a brand layer. Four hops accommodates brands and component aliases at the cost of a resolution path nobody holds in their head.
  • Resolve at build time vs preserve references in output. Flattening every reference produces the smallest, fastest CSS and destroys the ability to override a mid-chain link at runtime. Preserving references with outputReferences keeps runtime theming possible and makes the output harder to read and slightly slower to resolve.
  • Fail on a broken reference vs fall back silently. A build that fails on an unresolved reference catches the error where it was introduced. One that emits the literal reference string produces CSS that renders with a missing value, which is invisible until someone looks at the right component in the right theme.
  • Aliases as a migration tool vs aliases as architecture. A temporary alias pointing an old name at a new one is a deprecation mechanism with a removal date. A permanent alias that exists only to avoid a rename is technical debt with a token name.

Build Pipeline / Workflow Steps Link to this section

  1. Parse the token tree and build a reference graph. Every {path.to.token} reference becomes an edge. This graph is the input to every check that follows, and building it once per run is cheaper than resolving repeatedly.
  2. Detect cycles before resolving anything. A depth-first walk with a visited set finds them in linear time and reports the participating nodes, which is the only useful error message for a cycle.
  3. Enforce a maximum depth. Pick a number — four is typical — and fail above it. The limit is arbitrary; the value comes from it existing at all, because chains grow one reasonable hop at a time.
  4. Resolve to literals, keeping the chain recorded. The resolved value goes into the artefact; the chain goes into the manifest, where the audit and the documentation generator can read it.
  5. Check convergence for tokens that should agree. Any two semantic roles declared as a contrast pair, or any brand alias and its default, should resolve to values you can compare. Divergence is not always wrong, but it should be deliberate.
  6. Emit with or without references, per platform. CSS output for a themed product keeps references so a theme block can re-point a mid-chain link. Output for a native platform flattens, because those platforms have no equivalent of the cascade.
  7. Diff the resolved values against the previous build. This is what catches the case where a chain silently started resolving somewhere new.
Flattened output compared with preserved references The same token chain compiled two ways: flattened into literal values, and emitted with var() references preserved, with the runtime consequence of each shown underneath. Flattened --btn-bg: #2563eb; --color-action: #2563eb; smallest output, fastest resolution a theme block cannot re-point mid-chain right for native platforms References preserved --btn-bg: var(--color-action); --color-action: var(--brand-accent); a theme re-points one link, all consumers follow slightly larger, resolved by the engine right for themed web output
The choice is not about file size. Preserving references is what makes a runtime theme possible at all; flattening is what makes a static platform build correct.

Validation & Quality Gates Link to this section

// scripts/check-references.mjs — cycle detection and depth limit in one walk.
import { readFileSync } from 'node:fs';

const REF = /^\{([^}]+)\}$/;
const MAX_DEPTH = 4;
const tokens = JSON.parse(readFileSync('dist/token-manifest.json', 'utf8'));

function chain(name, seen = []) {
  if (seen.includes(name)) {
    throw new Error(`Cycle: ${[...seen, name].join(' → ')}`);
  }
  const token = tokens[name];
  if (!token) throw new Error(`Unresolved reference: ${name} (from ${seen.at(-1)})`);
  const match = REF.exec(String(token.$value));
  if (!match) return [...seen, name];
  return chain(match[1], [...seen, name]);
}

let failed = 0;
for (const name of Object.keys(tokens)) {
  try {
    const path = chain(name);
    if (path.length - 1 > MAX_DEPTH) {
      console.error(`Depth ${path.length - 1} exceeds ${MAX_DEPTH}: ${path.join(' → ')}`);
      failed++;
    }
  } catch (error) {
    console.error(error.message);
    failed++;
  }
}
process.exit(failed ? 1 : 0);
Tool Purpose Integration point
check-references.mjs Cycle detection, unresolved references, depth limit Runs on the manifest, before the CSS is emitted
Style Dictionary strict mode Fails the build on an unresolvable {reference} rather than emitting it Compiler configuration, no extra job
Resolved-value diff Compares each token’s final value against the previous release Pull request job, alongside the breaking change detection
Convergence check Asserts declared token pairs still resolve to comparable values Same job as the contrast gate

The depth limit deserves a note on how to choose it. Count the tiers the architecture actually has — primitive, semantic, brand, component is four — and set the limit to that number. A chain longer than the number of tiers means at least one hop is within a tier, which is the shape that produces unmaintainable graphs.

Convergence Checks in Practice Link to this section

Cycle detection and depth limits are structural: they ask whether the graph is well formed. Convergence checking asks a different question — whether two chains that are supposed to agree still do — and it is the check that catches the failure described at the top of this page.

The input is a declared list of relationships, not a discovery pass. Three kinds are worth declaring:

Contrast pairs. A text role and the surface roles it is permitted to sit on. The check resolves both, computes the ratio, and fails below the threshold. This is the same list the colour architecture gate consumes, so declaring it once serves both.

Brand defaults. Every brand alias should resolve to something, and the default brand’s value is the reference point. A brand that resolves a required alias to the same literal as the default is usually a brand file that failed to load rather than a brand that genuinely matches.

State families. A base role and its hover, active and disabled variants should resolve to values that stand in a consistent relationship — typically monotonic in lightness. A hover state that ended up lighter than its base in one theme and darker in another is a real defect that no individual token check can see.

Each of these is a few lines once the resolver exists, because the hard part — turning a name into a literal, per theme, per brand — is already done. The output is worth formatting as a table rather than a list of failures: seeing all three state families side by side is what makes an inconsistent one obvious.

The cost of convergence checking is maintaining the declared lists, and that cost is real. Keep the lists in the token repository next to the tokens they describe, and treat adding a semantic role without adding it to the relevant list as an incomplete change — the same way a new function without a test is.

Cross-Domain Dependency Mapping Link to this section

Parent Section Sibling Area Integration point Validation strategy
Token Fundamentals Colour palette architecture Semantic roles alias ramp steps Convergence check on declared contrast pairs
Multi-Brand Architecture Theme contract versioning Brand aliases sit mid-chain and must stay resolvable Conformance check per brand file
CI Pipelines JSON Schema validation Reference syntax is validated before the graph is built Schema pattern for {path} values
Theming & Dark Mode Runtime theme switching Theme blocks re-point a mid-chain link at runtime Assert resolved values per theme
/* @depends chain: --btn-bg → --color-action → --brand-accent → primitive
   Preserved references are load-bearing here: the dark theme re-points
   --color-action only, and every consumer downstream follows automatically.
   Flattening this output would silently break theming. */
.button {
  background: var(--btn-bg);
}

Production Code Reference Link to this section

{
  "brand": {
    "accent": { "$value": "#2563eb", "$type": "color" }
  },
  "color": {
    "action": {
      "$value": "{brand.accent}",
      "$type": "color",
      "$description": "Primary interactive colour. Re-pointed per theme and per brand."
    },
    "action-hover": {
      "$value": "{color.action}",
      "$type": "color",
      "$description": "Hover state. Derived at build time, never authored directly."
    }
  }
}

Why this works: color.action-hover referencing color.action is a same-tier hop, which the depth limit will flag — deliberately. The correct expression is a build-time derivation from the referenced value rather than an alias, and the gate is what surfaces the distinction. An alias says “these are the same thing”; a derivation says “this one is computed from that one”, and only the second survives a brand supplying an unexpected colour.

Three shapes of broken reference graph Three panels illustrate a dangling reference pointing at a missing token, a two-node cycle, and a divergent pair where two chains that should converge resolve to different primitives. Dangling --btn-bg {color.acton} — typo renders as nothing Cycle --color-action --brand-accent build never terminates Divergent --btn-bg → blue-600 --link-color → blue-700 both valid, both compile only a convergence check sees it
The first two fail loudly with the right gate in place. The third compiles cleanly and is the one that reaches production.

Naming a Token You Intend to Alias Link to this section

A surprising amount of alias trouble is really naming trouble. A token that will be referenced by others is a public name; one that exists only as an intermediate hop is not. Making that distinction visible in the name saves both the reader and the linter a lookup.

The convention that works is simple. Semantic roles — the names other tiers are expected to reference — read as roles: color-action, color-surface-raised, space-inset-md. Intermediate aliases that exist for a mechanical reason read as what they are: brand-accent-resolved, color-action-computed. The second form is a signal to a reviewer that referencing it directly is probably a mistake, and it gives the audit script a pattern to report on.

There is a related question about whether an alias should ever be published in documentation. The answer follows from the same logic: publish the names you want people to depend on, and leave the mechanical hops out of the reference material entirely. A documentation site that lists every token in the graph — including four pass-through aliases per role — teaches contributors that the graph is complicated, which is exactly the impression a well-designed alias structure should avoid.

A third naming question arises with derived tokens. A hover state computed from an action colour is not an alias, and naming it as though it were — color-action-hover sitting beside color-action — implies a relationship the build may or may not enforce. Where the derivation is genuinely computed, say so in the $description, so that a designer who wants to override it knows they are fighting a rule rather than editing a value.

When two names should resolve to one value Link to this section

Finally, a case worth stating explicitly because it is often treated as duplication to be removed. Two semantic roles that happen to resolve to the same primitive today are not redundant if they express different intents. color-border-subtle and color-surface-sunken may both be the same grey this year, and a well-meaning cleanup that collapses one into the other removes the ability to change them independently — which is precisely what the next redesign will need.

Convergence checks are therefore about declared relationships, not about coincidence. Two tokens resolving to the same value is fine. Two tokens that are declared to be a contrast pair resolving to values that no longer meet the threshold is not.

Diagnostic Matrix Link to this section

Diagnostic step Execution detail
Print the resolved chain for one token node scripts/chain.mjs --token --btn-bg should print every hop and the final literal
Compare a token across themes Resolve the same name with each theme’s overrides applied; the chains should differ only at the re-pointed link
Look for same-tier hops Any edge whose two endpoints share a tier prefix is a candidate for conversion to a derivation
Check the compiled output for { A literal brace in the emitted CSS means a reference escaped resolution
Diff resolved values against the last release Any token whose value changed without its own definition changing points at a mid-chain edit

Three root causes cover nearly every reference bug. A rename upstream leaves downstream tokens dangling, which is why renames need the deprecation cycle described in theme contract versioning. A same-tier alias creates a chain the depth limit was meant to prevent, usually introduced to avoid duplicating a value. And a partially-applied brand override re-points some links but not others, producing the divergence case above.

Aliases Across Repository Boundaries Link to this section

Everything above assumes a single token repository resolving its own references. Once a second repository publishes tokens that the first one references, the resolution problem acquires a version dimension that is worth thinking about before it arrives.

The common shape is a platform design system publishing a base token package, and a product team publishing their own tokens that alias into it. Product tokens reference base tokens; base tokens never reference product ones. The direction is fixed, which keeps the combined graph acyclic no matter how many products exist.

What is not automatic is that the graph is resolvable at all. A product pinned to base version 3.1 that references a token introduced in 3.2 gets a dangling reference at build time — and the message will name the token, not the version mismatch, which sends people looking in the wrong file. The practical mitigation is to include the resolved base version in the manifest and print it alongside any unresolved-reference error, so the message reads “unresolved reference in base@3.1” rather than merely “unresolved reference”.

A second consequence is that flattening choices propagate. If the base package ships fully flattened CSS, a product’s alias into a base semantic role becomes an alias to a literal, and the product loses the ability to theme through it. This is invisible until a theme is added, at which point the product team concludes that theming is broken in their application rather than in a dependency’s build configuration.

The rule that avoids both problems is to publish references, not values, across a package boundary, and to let the final consumer decide what to flatten. The base package’s CSS keeps var() hops for every token a theme might reach; the product’s build makes the flattening decision once, with full knowledge of which themes exist. That inverts the usual instinct — packages tend to want to ship optimised output — but the optimisation belongs at the point where the whole graph is visible.

Versioning an alias target Link to this section

One further subtlety: changing what an alias points at is a value change from the consumer’s perspective, even though the alias itself was not edited. A base package that re-points color.action from blue.600 to blue.700 has changed every consumer’s rendering without touching any token those consumers reference by name. Whether that is a patch or a minor release is a policy decision, but it must be a stated one, and the resolved-value diff described earlier is what makes it detectable in the first place.

Frequently Asked Questions Link to this section

How deep should an alias chain be allowed to go? Link to this section

At most one hop per architectural tier, which in a typical system means four: component, semantic, brand, primitive. The limit is worth enforcing even though the specific number is arbitrary, because chains never grow by design — they grow by one apparently harmless hop at a time, each justified locally. A build-time limit turns the fifth hop into a conversation rather than a discovery six months later.

Should the compiled CSS keep var() references or flatten them? Link to this section

Keep them for web output, flatten for everything else. Preserving references is what allows a theme block to re-point a single mid-chain token and have every consumer follow, which is the entire mechanism behind runtime theming. Native platforms have no equivalent of the cascade, so a reference there is meaningless and flattening is the only correct output.

What is the difference between an alias and a derived token? Link to this section

An alias asserts that two names mean the same value: change the target and the alias follows, always. A derived token is computed from another value by a rule — a hover state that is one ramp step darker, an on-colour chosen for contrast. The distinction matters because a derivation stays correct when the source changes unexpectedly, and an alias does not. If a relationship has a rule behind it, express the rule.