Skip to content
Shenzhen · The Greater Bay Area · Earth

Design Tokens as Typed Code: If the Build Does Not Consume It, It Is Documentation

The difference between a design system and a design document is whether the compiler reads the tokens. I keep the token source in a typed module, generate the CSS from it, and let CI fail when the two disagree.

7 min read1,441 words
Design EngineeringDesign SystemsNot yet translated.

A token file that nothing consumes is documentation

If the build does not read your token file, you have written documentation, and documentation decays at the speed of the team's attention. The only tokens that cannot drift from production are the ones a type checker and a CSS generator both consume from the same source module.

I have maintained the other kind. A tokens.json in the repository, a matching Figma library, and a short wiki page explaining which values are current. It looked disciplined for about two quarters. Then someone added a surface-muted step for a modal, an engineer inlined oklch(0.96 0.004 250) because the token did not exist yet, and the file quietly became a description of what the system used to be. Nobody decided to abandon it. It simply fell off the path between an intent and a pixel.

A design token is not a value with a name; it is a name the build resolves, and a token file that no compiler or CSS generator reads is documentation that will be wrong within two quarters.

That distinction changes what a token is. In the documentation model, a token is a convention people are asked to respect, and enforcement happens in review. In the compiled model, a token is the only way to produce a value, and there is nothing to respect because there is no alternative to reach for. The first model depends on everyone remembering; the second depends on tsc.

Where does token drift actually come from?

Failure is rarely a rogue hex code typed by someone who did not care. It is a series of small, individually reasonable decisions, and almost none of them produce a compile error in the documentation model.

ChangeWhat the code still saysWhere it is caught
A colour value is adjusted in the design toolThe previous value, until CSS is regeneratedCI diff of the generated file
A token is renamedThe previous name, everywhere it was usedtsc, in every file that referenced it
A token is deletedThe previous name, forever, unstyledtsc, on the pull request
A step is missing, so a value is inlinedNothing at allA lint rule, or an audit six months later
Two token paths map to the same CSS variableThe later declaration, silentlyA unit test on the generator
A theme overrides the wrong selectorThe light value on a dark routeA screenshot test on the themed route

The rows with a real detection mechanism are the ones where something mechanical reads the tokens. The rows without one are the ones where a human eventually notices, and the lag between the change and the notice is where the visual debt accumulates. The general argument for removing that boundary rather than instrumenting it is the one I made when writing about closing the Figma-to-production gap without a handoff; this article is the mechanical half of it.

What does the build have to consume?

Two things, from one module: the type surface, and the CSS custom properties. If the types are hand-written next to a JSON file, you have moved the duplication rather than removed it, which is why I keep the source as a TypeScript module with as const and derive the union of valid token paths from the value itself.

// tokens/tokens.ts — the source of truth. Nothing else defines a visual value.
type Leaf = { readonly $type: "color" | "dimension" | "number"; readonly $value: string | number };

export const tokens = {
  color: {
    surfaceBase: { $type: "color", $value: "oklch(0.98 0.003 250)" },
    surfaceRaised: { $type: "color", $value: "oklch(0.99 0.002 250)" },
    accentDefault: { $type: "color", $value: "oklch(0.62 0.14 250)" },
    accentContrastText: { $type: "color", $value: "{color.surfaceBase}" },
    borderSubtle: { $type: "color", $value: "oklch(0.90 0.005 250)" },
  },
  space: {
    "2": { $type: "dimension", $value: "0.5rem" },
    "4": { $type: "dimension", $value: "1rem" },
    "6": { $type: "dimension", $value: "1.5rem" },
  },
  radius: {
    control: { $type: "dimension", $value: "6px" },
    panel: { $type: "dimension", $value: "14px" },
  },
} as const satisfies Record<string, Record<string, Leaf>>;

type Paths<T, Prefix extends string = ""> = {
  [K in keyof T & string]: T[K] extends Leaf
    ? `${Prefix}${K}`
    : Paths<T[K], `${Prefix}${K}.`>;
}[keyof T & string];

export type TokenPath = Paths<typeof tokens>;

TokenPath resolves to "color.surfaceBase" | "color.accentContrastText" | "space.2" | "radius.panel" | ... — eleven members for the set above, and exactly as many as the object has leaves. Adding a token widens the union without anyone editing a type. Removing one narrows it and breaks the build at every call site, which is the entire point.

The alias matters more than it looks. accentContrastText does not repeat a colour, it points at surfaceBase, so retheming the surface moves the contrast text with it. That is the DTCG reference syntax, and keeping it in the source rather than expanding it at authoring time is what makes a two-theme setup tractable.

Where the tokens liveWhat the build readsRename safetyHow drift surfaces
Hand-maintained CSS variablesNothing; people copy valuesNoneDesign review, weeks later
JSON exported one way from the design toolA generator, code side onlyNames, noOne component renders unstyled
JSON in the repository, generated CSSCSS, not typesValues, noVisual regression, sometimes
Typed module, generated CSS, CI gateTypes and CSSCompile error at every call sitetsc on the pull request

How do you generate the CSS from the typed module?

The generator is short enough that owning it is cheaper than configuring a token tool. It walks the object, converts each path to kebab-case, and resolves aliases into var() references.

// scripts/build-tokens.ts — runs before next build; emits app/tokens.css
import { writeFileSync } from "node:fs";
import { tokens } from "../tokens/tokens";

type Leaf = { readonly $type: string; readonly $value: string | number };

const kebab = (segment: string): string => segment.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
const variable = (path: string[]): string => `--${path.map(kebab).join("-")}`;

function collect(node: unknown, path: string[] = []): Array<[string, Leaf]> {
  return Object.entries(node as Record<string, unknown>).flatMap(([key, value]) => {
    if (value !== null && typeof value === "object" && "$value" in value) {
      return [[variable([...path, key]), value as Leaf]] as Array<[string, Leaf]>;
    }
    return collect(value, [...path, key]);
  });
}

const reference = /^\{([^}]+)\}$/;

const body = collect(tokens).map(([name, leaf]) => {
  const raw = String(leaf.$value);
  const alias = reference.exec(raw);
  return `  ${name}: ${alias ? `var(${variable(alias[1].split("."))})` : raw};`;
});

writeFileSync("app/tokens.css", `:root {\n${body.join("\n")}\n}\n`, "utf8");

The output is a flat :root block — --color-surface-base: oklch(0.98 0.003 250);, then --color-accent-contrast-text: var(--color-surface-base);, then the dimensions and radii. Flat rather than nested because a custom property cannot contain a dot in its name and because var() resolution is what gives the alias its cascade. The camelCase-to-kebab conversion is duplicated between this file and the runtime helper, which is a real cost: it means a token named surfaceBase and another named surface-base would collide into one variable. I keep a generator unit test that asserts the emitted variable names are unique, because that collision fails silently in the browser and only shows up as a wrong colour.

How does a component read a token without escaping the types?

Through one function that takes TokenPath and returns a var() string. No component should be allowed to spell a variable name by hand.

// lib/tokens.ts
import type { TokenPath } from "@/tokens/tokens";

const kebab = (segment: string): string => segment.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);

export const token = (path: TokenPath): string =>
  `var(--${path.split(".").map(kebab).join("-")})`;

// components/panel.tsx
import type { CSSProperties, ReactNode } from "react";
import { token } from "@/lib/tokens";

type ColorPath = Extract<TokenPath, `color.${string}`>;
type SpacePath = Extract<TokenPath, `space.${string}`>;

interface PanelProps {
  surface?: ColorPath;
  padding?: SpacePath;
  children: ReactNode;
}

export function Panel({ surface = "color.surfaceRaised", padding = "space.4", children }: PanelProps) {
  const style: CSSProperties = {
    background: token(surface),
    padding: token(padding),
    border: `1px solid ${token("color.borderSubtle")}`,
    borderRadius: token("radius.panel"),
  };
  return <section style={style}>{children}</section>;
}

Writing token("color.surface") is a compile error naming the valid members, and passing "space.4" into a ColorPath prop is rejected before anything renders. That is the difference the thesis turns on: the rename is no longer something a reviewer has to catch, because the code that used the old name stops building.

How does CI prove the two artefacts agree?

The type check covers the type surface. It does not cover the generated CSS, because that file is an artefact and can be stale. One check closes the gap:

# package.json: "prebuild": "tsx scripts/build-tokens.ts"
pnpm exec tsx scripts/build-tokens.ts

if ! git diff --quiet -- app/tokens.css; then
  echo "app/tokens.css is stale — run pnpm tokens:build and commit the result."
  git --no-pager diff --stat -- app/tokens.css
  exit 1
fi

Regenerating in CI and failing on a non-empty diff means the committed CSS is always the output of the committed source. It is a crude check and it is the one that actually holds, because it does not depend on anyone remembering a step. I run it as a prebuild script so the local build cannot produce a stale file either, and the CI gate then only catches a forgotten commit.

When is a typed token pipeline the wrong call?

Plenty of times. If you have one application, one theme, one author and fewer than roughly forty tokens, a CSS file of custom properties with a comment at the top is genuinely sufficient, and the generator is ceremony. The pipeline earns its cost when more than one consumer reads the tokens, or when a rename has to be safe across files you are not currently looking at.

The union also degrades at scale. A TokenPath of several hundred members produces error messages that print the entire union, and tsc output becomes something people skim rather than read. I split the union into ColorPath, SpacePath and TypographyPath once a single union passes roughly three hundred members, and the only reason is message length, not compile time. If your tokens come from a design tool that non-engineers own, generating from the repository makes the repository the gate for every visual value, and if designers will not open a pull request, that is a political problem a build script cannot solve.

Two escape hatches survive all of this, and I want to be explicit about them. A typed union cannot stop someone inlining a literal colour inside a component, so I add a lint rule that rejects hex and oklch() literals outside the token module; that rule catches more real defects than the union does. And generated files produce diff noise, so I keep exactly one generated artefact, commit it, and never hand-edit it — a generated file under review is only useful if reviewers read the source instead.

What to do first

Take the twenty values you reach for most, put them in a typed module with the shape above, write the forty-line generator, and add the CI diff check. Then delete the document that listed them, because leaving it in place gives people a second, slower way to answer the same question and no way to know which answer is current. The measure of whether it worked is not elegance: it is that a rename produces a failing build instead of a support ticket, and that the values in production are provably the values in the repository. Everything else about design systems is downstream of that guarantee, and a token file without it is a promise nobody can verify.

Keep reading

More in Design Engineering

Ready to build a system?[ Book a Call ]