The copy-paste tax

Every design token system eventually grows a second copy of itself.

A designer sets a colour in Figma. An engineer copies the hex value into a stylesheet. Three weeks later the designer changes the colour. The stylesheet still holds the old value, and the only symptom is a pixel that no longer matches the design.

The copy is the bug. Not the Figma file, not the CSS, and not anyone's diligence.

This post shows one way to remove it. Figma variables become a committed JSON file. That file generates CSS custom properties and a TypeScript type. Product code asks for tokens by name through a typed API, so a token that does not exist is a compile error instead of a blank box.

The example is a real pnpm and Nx workspace. Every command printed below was run against it, with three exceptions. The scaffold commands were not re-run here, though the generators they name are the ones that built the workspace and each one exists. The PowerShell clipboard line needs a shell this session could not run, so the export was done by pasting the payload instead. And the plugin has never been run inside the Figma desktop app, so the export step was exercised through a stand-in for the Figma API.

The pipeline in one picture

Figma desktop (Plugin API) -> tokens/tokens.json committed, reviewable -> libs/tokens/src/tokens.css generated -> libs/tokens/src/tokens.generated.ts generated -> apps/demo imports @tokens/core 

One rule holds the whole thing together: no build step talks to Figma.

The export step runs on a machine with Figma installed. Everything after it reads a file. That is what makes the build hermetic, meaning it needs no network and no third-party service to succeed.

Five rules

This is the part worth keeping. The pipeline below is the proof. These are the rules it produced.

Each rule names the commit that proves it. 28cd98c is this repository's first commit: the workspace was rebuilt from Nx generators, so three of the five rules land there.

1. Make the token artifact an input to the affected graph. The committed artifact sits outside every project root, so a token-only commit affects nothing and nx affected reports an empty set. One line in the target's inputs fixes it. This is the rule to apply on Monday. (28cd98c)

2. Compare the expected output byte for byte, and keep the checks that look inside the artifact. A hand-written list of semantic checks collapsed into one comparison, because the expected bytes are derived from the artifact. Two kinds of fault cannot live in that comparison: a token whose alias disagrees with its target, and two tokens claiming the same name or the same CSS anchor. Those stay as explicit checks, because a byte comparison cannot see a fault that the artifact reproduces faithfully. (28cd98c)

3. Take the unit from the scope, never from the value. Figma stores every number unitless. The variable's scope is the designer's declaration of what the number is, so the scope says whether a number is a length and the renderer adds the unit. The same rule has a different spelling in the Design Tokens format, where a dimension is { "value": 8, "unit": "px" }. The unit belongs to the type, not to a suffix someone typed onto the value. (4fdb408)

4. Put the negative case in the contract. One @ts-expect-error on a token name that does not exist is what makes the guarantee a type rather than a convention. If the union ever widens, the compiler reports an unused directive and the build fails. (28cd98c)

5. A read path that fails open is its own failure mode. Rules 1 to 4 are what this repository does. Rule 5 is what a second implementation, in another repository we maintain, got wrong, and it is the rule that made this one real. (2909b77)

Resolving an alias by mode name is not tidiness. It is the difference between a capture of a file and a capture of a value. A sibling implementation of this capture reads the raw valuesByMode map instead, so an unresolved VARIABLE_ALIAS yields NaN for each channel and color/text/default comes back as the literal #NaNNaNNaN. The snapshot still reads, still hashes, and still agrees with the payload that carries it. The value is simply wrong, and it is wrong in a way that survives every check that only looks at shape. Which is the argument for resolving the alias in the capture, instead of trusting the file to hand you a value it has not got.

A dimension written as an object ({"value":8,"unit":"px"}) is a value, not a string. A token reader that stringifies whatever it finds will hand you the JSON text, so the same $type: dimension produces 8px for one token and {"value":8,"unit":"px"} for the next. The pipeline has to branch on the type, or the contract it publishes is two contracts wearing one name.

Both are the same mistake as rule 3, running the other way. A write path that refuses what it cannot represent is easy to notice. A read path that guesses is not, because the output looks like a value and passes every check that asks only about shape.

The workspace

Four projects in one pnpm workspace, with Nx 23.2.1 as the task runner.

token-pipeline/ ├── package.json pnpm workspace root, Nx runner ├── nx.json the two inference plugins ├── tokens/tokens.json the committed artifact ├── tools/token-export/ the Figma export side │ ├── figma-plugin/ reads variables, resolves aliases │ └── src/write-artifact.ts validates the payload, writes the artifact ├── tools/tokens-plugin/ the Nx plugin that owns the generator │ ├── generators.json the generator entry point │ └── src/lib/render.ts artifact -> CSS + TypeScript ├── libs/tokens/ @tokens/core │ ├── package.json the generate and check targets │ └── src/ │ ├── index.ts the typed API (hand written) │ ├── tokens.generated.ts generated │ ├── tokens.css generated │ └── type-contract.test.ts the compile-time contract test └── apps/demo/ @tokens/demo, product code 

Plain English: libs/tokens is the library product code imports. apps/demo is product code. tools/token-export is the Figma half. tools/tokens-plugin is an Nx plugin that lives inside the workspace and owns the generator.

The dependency direction matters more than the tooling. apps/demo depends on @tokens/core. @tokens/core depends on nothing. The generator reads tokens/tokens.json and writes into libs/tokens/src. Nothing in the workspace imports a Figma SDK, and @tokens/core does not depend on @nx/devkit, so the DevKit never reaches product code.

@nx/eslint enforces that direction rather than merely recording it. The type:lib and scope:tokens tags in each project's package.json feed the @nx/enforce-module-boundaries rule, so nothing inside the token pipeline may reach outside it. That single constraint is what keeps the DevKit and the Figma tooling out of product code.

The targets are inferred

nx.json registers @nx/js/typescript and @nx/eslint/plugin, and Nx derives a typecheck and a lint target for each of the four projects. nx show project tokens --json reports the resolved shape:

Target Source Command
typecheck inferred tsc --build tsconfig.json --emitDeclarationOnly
lint inferred eslint .
generate hand-written nx g @tokens/plugin:tokens
check hand-written nx g @tokens/plugin:tokens --check

Plain English: typecheck and lint are not configured anywhere. Nx derives them from each project's TypeScript and ESLint setup, and runs them with the project directory as the working directory. Only the two token targets are written by hand, and both are thin wrappers over the generator.

There is a custom Nx plugin in this workspace, and it is worth being precise about how Nx finds it. @tokens/plugin is not listed in nx.json. It is discovered as a local workspace plugin, because tools/tokens-plugin/package.json declares "generators": "./generators.json". nx list shows it under "Local workspace plugins". Listing it in nx.json as well would register the same plugin twice.

Figma is the source of truth, but the build cannot see it

Figma exposes variables over a REST API. Those endpoints require a full seat in an Enterprise organisation, and a Pro seat cannot call them (Figma Variables REST API). "Fetch variables from Figma inside CI" was therefore not an option here.

Rejected approach: CI calls the REST API and generates tokens at build time. Three reasons, in order of weight. It is seat-gated on this team. It makes every build depend on a third-party API being reachable, which breaks hermetic builds and offline work. And a token change produces no reviewable artifact, so a designer can change a colour and nobody sees it until the pixels differ.

Design choice it forced: export locally through the Figma Plugin API, and commit the result.

The Plugin API is a different surface, and it is available to any plugin running inside the Figma desktop app. The plugin reads the open file and hands back JSON as text, because a plugin cannot write to your filesystem. A small script on the other end validates that JSON and writes it to tokens/tokens.json.

An excerpt of the artifact, abbreviated and re-wrapped for width. The names, the values and the code syntax come from Figma. The type is the pipeline's own, and it exists to carry a fact Figma does not store: whether a number is a length.

{ "artifactVersion": 2, "exportedAt": "2026-10-25T04:12:00.000Z", "source": { "fileName": "Design System", "fileKey": null, "pluginVersion": "0.2.0" }, "collections": [ { "id": "VariableCollectionId:1:1", "name": "Core", "defaultModeId": "1:0", "modes": [ { "id": "1:0", "name": "Light" }, { "id": "1:1", "name": "Dark" } ] } ], "tokens": [ { "name": "color/text/default", "type": "COLOR", "codeSyntax": { "WEB": "var(--color-text-default)" }, "collectionId": "VariableCollectionId:1:1", "values": { "Light": "#111111", "Dark": "#f5f5f5" }, "alias": { "Light": "color/palette/gray/900", "Dark": "color/palette/gray/900" } }, { "name": "space/gutter", "type": "LENGTH", "codeSyntax": { "WEB": "space-gutter" }, "collectionId": "VariableCollectionId:1:1", "values": { "Light": "16", "Dark": "16" } } ] } 

The variable name comes from Figma. The CSS anchor comes from Figma's own code syntax, which is the per-platform name a designer configures in the variable's settings. The value per mode comes from the variable itself.

Where one variable aliases another, the plugin resolves the alias by hand, because Figma returns the alias rather than the value it points at. Figma's Plugin API documentation says that valuesByMode "will not resolve any aliases", so the plugin walks the alias chain itself, following it by mode name and refusing to follow a cycle.

One detail decides where the CSS name comes from. codeSyntax.WEB is the only source of the custom property name, because a name we derived from the variable name would be a second convention that can drift from the first. Figma stores whatever the designer typed, and three forms turn up in practice. The generator accepts all three and normalises them to the same result:

Figma code syntax CSS custom property What changed
color-bg-default --color-bg-default the -- prefix is added
--color-bg-default --color-bg-default nothing changes
var(--color-bg-default) --color-bg-default the var() wrapper is stripped

The name itself is never invented, and a variable with no code syntax fails the build with an explicit message. A name that is not a legal CSS custom property fails the same way, so a designer who writes the Figma variable name straight into the code syntax field, slashes and all, gets a build error instead of a silently wrong property. The artifact in this repository exercises two of those three forms on purpose: seven tokens store the full var(--name) reference and one stores the bare name, and both generate the same CSS.

Figma has no length type. It stores every number unitless (a spacing token is 16, not 16px), and what the number means depends on the field it is used in.

The pipeline reads that from the variable's scope: the fields the designer allowed the variable in. A number scoped to a pixel field is a LENGTH and is rendered with px. A number scoped to OPACITY or FONT_WEIGHT stays unitless, because those fields take a bare number. A number with no scope, or one scoped to a field where Figma accepts more than one unit, is refused rather than guessed at.

Plain English: the scope is the designer's declaration of what the number is, in the same way the code syntax declares what the CSS property is called. The artifact carries the number and the type, and the renderer adds the unit: the artifact can never carry a hand-written one, and 16px is rejected as a LENGTH, because that would be the unit twice.

In the real file, space/gutter is scoped GAP and the radius token is scoped CORNER_RADIUS, so both are lengths. That is why the generated CSS reads --space-gutter: 16px.

Two more details. The fileKey field is null, because Figma exposes figma.fileKey only to private plugins, and this plugin is neither. And exportedAt changes on every export, so a re-export always produces a one-line diff in the artifact, and one in the header of each generated file.

The real file has now been exported and checked. Figma exports variables natively, one file per mode, in the Design Tokens Format. That export of the Design System file carries eight variables across Light and Dark, one of them an alias, and it passes every check the generator makes: the single theme axis, the alias match, the unique names, the unique CSS anchors, the length rule, and the render. The whole file does not, for the reason given under "What this does not fix".

The plugin's behaviour is covered by tests that need no Figma at all. pnpm --filter @tokens/export test runs the export against a stand-in for the Figma API and checks the alias walk, the ordering, the refusals and the scope rules. It also checks one thing that is easy to get wrong: a value the export could not read is written as null, never as a string that looks like a value, so a broken colour cannot reach the CSS as #NaNNaNNaN.

The plugin's own payload has not been produced by a run inside the Figma desktop app. Its code has been run against the real file's values and scopes through a stand-in for the Figma API, and Figma's native variable export of the same file has been checked through the pipeline's own validations.

The committed tokens/tokens.json is still hand-authored rather than exported: its export timestamp is a placeholder, its hex values are lower case where Figma writes upper case, and it calls the radius token radius/md where Figma calls it space/radius/md. The CSS anchor is the same either way.

The typed API

The generator turns the artifact into two files. One is CSS. The other is the contract:

/** Every token name, mapped to the CSS custom property it resolves to. */ export const TOKEN_CSS_VARS = { "color/bg/default": "--color-bg-default", "color/bg/surface": "--color-bg-surface", "color/border/default": "--color-border-default", "color/palette/gray/900": "--color-palette-gray-900", "color/text/default": "--color-text-default", "color/text/muted": "--color-text-muted", "radius/md": "--radius-md", "space/gutter": "--space-gutter", } as const; /** The token contract: a token name is a value of this union, nothing else. */ export type TokenName = keyof typeof TOKEN_CSS_VARS; 

Plain English: as const stops TypeScript from widening the values into plain strings, so the compiler knows each token maps to one specific property name. keyof then turns the object keys into a union of allowed names.

The hand-written API on top of it is deliberately small:

import { TOKEN_CSS_VARS, type TokenName } from './tokens.generated'; export function tokenVar<K extends TokenName>(name: K): (typeof TOKEN_CSS_VARS)[K] { return TOKEN_CSS_VARS[name]; } export function token<K extends TokenName>(name: K): string { return `var(${TOKEN_CSS_VARS[name]})`; } 

Plain English: K extends TokenName means a token name parameter only accepts names in the union. tokenVar returns the property name with its exact literal type, so tokenVar('color/bg/default') has the type '--color-bg-default'. token returns a var() reference ready to drop into a style object.

The guarantee is enforced by the compiler, not by a comment. This file is the test:

import { token, tokenVar, type TokenName } from './index'; /** A token that exists compiles, and returns the expected custom property. */ export const backgroundVar: '--color-bg-default' = tokenVar('color/bg/default'); /** An unknown token name must not compile. */ // @ts-expect-error 'color/bg/nope' is not in tokens.json, so this call must not compile. export const unknownToken: string = token('color/bg/nope'); /** Token names are case-sensitive, so a plausible typo must not compile. */ // @ts-expect-error 'color/Bg/Default' differs from 'color/bg/default' only in case. export const wrongCase: string = token('color/Bg/Default'); /** The token name union itself rejects values that are not tokens. */ // @ts-expect-error 'color/bg/nope' is not assignable to TokenName. export const notATokenName: TokenName = 'color/bg/nope'; 

Plain English: there is no test runner here, and this is not a runtime test file. It is a compile-time contract test. libs/tokens/tsconfig.lib.json includes it, and the typecheck target compiles that file with tsc --build, so the contract is checked on every run. Each @ts-expect-error asserts that the line below it fails to compile, and if it ever starts compiling, the compiler reports an unused directive and the build fails.

I checked that this bites in both directions. A call to token('color/bg/nope') without the directive fails with TS2345; a directive on a line that does compile fails with TS2578: Unused '@ts-expect-error' directive. The contract cannot rot quietly.

Product code then looks like this, with no hex value anywhere in sight:

import { token } from '@tokens/core'; const card = { background: token('color/bg/surface'), color: token('color/text/default'), border: token('color/border/default'), padding: token('space/gutter'), radius: token('radius/md'), }; 

The generator is an Nx generator

Producing the CSS and the TypeScript is not a script. It is an Nx generator, it lives in tools/tokens-plugin, and it runs through nx g:

nx g @tokens/plugin:tokens # write the generated files nx g @tokens/plugin:tokens --dry-run # preview the changes nx g @tokens/plugin:tokens --check # fail if the committed files are stale 

Plain English: the same generator does three things. It writes the two files, it shows what it would write without writing it, and it compares what it would write against what is committed and fails if they differ.

The wiring is three files. tools/tokens-plugin/generators.json points at the implementation and at its schema:

{ "$schema": "../../node_modules/nx/schemas/generators.json", "generators": { "tokens": { "factory": "./src/generators/tokens/tokens", "schema": "./src/generators/tokens/schema.json", "description": "Generate CSS custom properties and TypeScript types from the committed token artifact." } } } 

The implementation is a default-exported async function that takes the DevKit Tree and the options:

import { joinPathFragments, type Tree } from '@nx/devkit'; export async function tokensGenerator(tree: Tree, options: TokensGeneratorSchema): Promise<void> { const raw = tree.read(options.artifact); // ... tree.write(cssPath, css); tree.write(typesPath, types); } export default tokensGenerator; 

Plain English: the generator never touches the filesystem. It reads the artifact and the committed files through the tree, and it writes the two generated files through the tree. Nx applies the tree to disk afterwards, which is what gives --dry-run for free.

One DevKit call is deliberately absent. The generator does not call formatFiles. A formatter would rewrite the generated files whenever Prettier changed its rules, and a token diff would carry that churn. The renderer writes exact bytes, and both generated files are listed in .prettierignore, so a formatter upgrade can never appear in a token diff.

The two project targets are thin wrappers over the same generator. This is the generate target, from the nx.targets block of libs/tokens/package.json:

"generate": { "executor": "nx:run-commands", "outputs": ["{projectRoot}/src/tokens.generated.ts", "{projectRoot}/src/tokens.css"], "inputs": [ "{workspaceRoot}/tokens/tokens.json", "{projectRoot}/src/tokens.generated.ts", "{projectRoot}/src/tokens.css" ], "options": { "command": "nx g @tokens/plugin:tokens", "cwd": "{workspaceRoot}", "env": { "NX_DAEMON": "false" } } } 

Plain English: the target does not know how to generate anything. It runs the generator, and it names the files the generator owns so Nx can cache the result. The check target is the same thing with --check on the end of the command, and no outputs, because it writes nothing.

One API note, because it is easy to get wrong. createGenerator is not part of @nx/devkit's public API: it is absent from the file that defines that package's exports, packages/nx/src/devkit-exports.ts, at every release checked from 15.9.0 through 20.0.0. It was never part of that public surface, so there is no version to pin to. The modern shape is the default-exported generator function plus a generators.json entry pointing at a schema.json.

One comparison, and the three checks it cannot hold

The drift guard used to be a list of semantic checks. It re-read the artifact and the generated files and compared them property by property: that every artifact token had a CSS variable, that every generated variable existed in the artifact, that the names matched the code syntax, that every value matched the artifact, and more besides. Each check was mechanical, and each one was a different way for the list and the files to disagree.

That script is not in this repository. This workspace was rebuilt from Nx generators, and the rebuild carried the behaviour forward without the history. So the old list is described here rather than shown, and what a reader can check is the shape the guard has now.

The checks that compared the artifact with the output are now a single comparison. In --check mode the generator re-derives the expected output from the artifact and compares it to the files on disk, byte for byte:

for (const [path, next] of files) { const current = tree.read(path); if (current === null || current.toString('utf8') !== next) { stale.push(path); } } 

Plain English: instead of checking the output against a list of separate rules, the generator asks one question. Does the file on disk contain exactly the bytes this artifact would produce? If it does, every rule that could have been checked is satisfied. That is why the list went away rather than being maintained: a rule the byte comparison does not cover does not exist to be missed. The failure message names the file:

[token-generator] the generated token files are out of date: libs/tokens/src/tokens.css Run: nx g @tokens/plugin:tokens 

One check could not be folded in, and it stayed. Alias consistency is not a property of the generated files. It is a property of the artifact: if color/text/default aliases color/palette/gray/900, then the artifact has to say that the value of the first is the value of the second, in every mode. A generator that faithfully reproduces an inconsistent artifact would reproduce the inconsistency, and the byte comparison would pass. So that check runs explicitly, and it names both sides:

[token-generator] token "color/text/default" in mode "Light" is #222222, but "color/palette/gray/900" is #111111 

The old list is now one comparison and three explicit checks. The alias check had to stay, because it was never about the output. The name and anchor checks came later, and they stayed too, for the same reason.

That blind spot is structural, not incidental. The comparison looks at the gap between the artifact and the output, so a fault that lives in the artifact and is reproduced faithfully can never show up in it. Two were living there.

The first is two variables with the same name. Figma allows that when they sit in different collections (it is how a second brand is modelled), but this generator reads every local variable and cannot tell them apart:

[token-generator] token "color/brand/500" is defined in more than one collection ("Core", "Collection 1"). This generator reads every local variable and cannot tell them apart, so each token needs a unique name. 

The second is two variables claiming the same CSS anchor. The anchor comes from the Figma code syntax, and Figma lets two variables carry the same one. The generator would emit that custom property twice in every block, and the later declaration would win silently, so asking for one token would hand back the other one's value:

[token-generator] token "color/brand/500" uses the CSS custom property "--color-bg-default", which token "color/bg/default" already uses. Every token needs its own code syntax in Figma. 

Plain English: in both cases the generated files look fine. Nothing in the output is wrong, which is exactly why neither fault can be left to the byte comparison.

The guard compares bytes, and that takes work

A comparison of raw bytes is only meaningful if the bytes are the same on every machine. The generator emits LF line endings, and .gitattributes pins the three files that matter to LF:

libs/tokens/src/tokens.css text eol=lf libs/tokens/src/tokens.generated.ts text eol=lf tokens/tokens.json text eol=lf 

Plain English: on Windows, Git's default core.autocrlf=true checks text files out with CRLF. A fresh clone would then have CRLF in the generated files, the byte comparison would fail on a clean tree, and the guard would be noise. The pins make the comparison mean the same thing in CI and on a laptop.

The affected graph

The committed artifact at tokens/tokens.json sits at the workspace root, outside every project root. Nx maps files to projects by path, so as it stands the artifact belongs to no project. A commit that changes only a token would affect nothing, and nx affected would report an empty set.

The fix is to name the artifact as an input on the targets that read it:

"inputs": ["{workspaceRoot}/tokens/tokens.json", ...] 

An input that starts with {workspaceRoot}/ tells Nx that a change to that file touches the project that declares it. The artifact is registered on the generate and check targets, and deliberately not in targetDefaults, so the dependency stays visible and specific instead of being spread across every project in the workspace.

With that in place, changing a token touches tokens directly, and demo arrives transitively through the project graph. The repository carries a branch that changes one token, so the question can be asked for real:

git checkout feature/token-change pnpm nx show projects --affected --base=main 
tokens demo 

And the affected work runs, on a branch that changes one token:

pnpm nx affected -t typecheck --base=main 
 NX Running target typecheck for 2 projects and 1 task they depend on: - tokens - demo √ nx run tokens:typecheck √ nx run...
Source:https://dev.to/effinrich/design-tokens-as-a-compile-time-contract-figma-variables-to-a-typed-typescript-api-1fnn