Schemas for Composite Shadow and Typography Tokens
Part of JSON Schema Validation for Tokens. Scalar tokens are easy to validate. Composite types — shadow, typography, border, transition — have object values, optional fields and array forms, and they are where a permissive schema stops catching anything.
Prerequisites Link to this section
- A working schema validation setup, as built in validating design tokens against JSON Schema in CI.
- Ajv with
allErrorsenabled, so a composite value produces every failing field rather than the first. - Agreement on which composite types the system actually uses; validating a type nobody authors is wasted schema surface.
- The compiler’s expectations documented, since the schema’s job is to enforce the contract the compiler assumes.
Step-by-Step Implementation Link to this section
Step 1 — Define the reusable value types Link to this section
Intent: dimensions and colours appear inside every composite, so define them once.
{
"$defs": {
"dimension": {
"type": "string",
"pattern": "^-?(0|\\d*\\.?\\d+)(px|rem|em)$",
"description": "A length with an explicit unit. Bare 0 is rejected — write 0px."
},
"color": {
"type": "string",
"pattern": "^(#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})|rgb\\(.+\\)|oklch\\(.+\\))$"
},
"reference": {
"type": "string",
"pattern": "^\\{[a-zA-Z0-9._-]+\\}$"
}
}
}
Why this works: rejecting a bare 0 in the schema catches the same class of mistake @property registration catches at runtime, one stage earlier and with a better message. The reference definition matters because any field inside a composite may be an alias rather than a literal, which is the detail that most hand-written composite schemas miss.
Step 2 — Model the shadow object Link to this section
Intent: every field validated, optional fields optional, references allowed anywhere.
{
"$defs": {
"shadowLayer": {
"type": "object",
"additionalProperties": false,
"required": ["color", "offsetX", "offsetY", "blur"],
"properties": {
"color": { "oneOf": [{ "$ref": "#/$defs/color" }, { "$ref": "#/$defs/reference" }] },
"offsetX": { "oneOf": [{ "$ref": "#/$defs/dimension" }, { "$ref": "#/$defs/reference" }] },
"offsetY": { "oneOf": [{ "$ref": "#/$defs/dimension" }, { "$ref": "#/$defs/reference" }] },
"blur": { "oneOf": [{ "$ref": "#/$defs/dimension" }, { "$ref": "#/$defs/reference" }] },
"spread": { "oneOf": [{ "$ref": "#/$defs/dimension" }, { "$ref": "#/$defs/reference" }] },
"inset": { "type": "boolean" }
}
}
}
}
Why this works: additionalProperties: false is the field that gives the schema teeth. Without it, a typo — blurr instead of blur — validates cleanly, the compiler ignores the unknown key, and the shadow renders with a zero blur that nobody notices until a designer asks why the card looks flat.
Step 3 — Accept both the single and layered forms Link to this section
Intent: a shadow token may be one layer or several, and both are valid DTCG.
{
"shadowToken": {
"type": "object",
"required": ["$value", "$type"],
"properties": {
"$type": { "const": "shadow" },
"$value": {
"oneOf": [
{ "$ref": "#/$defs/shadowLayer" },
{ "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/shadowLayer" } },
{ "$ref": "#/$defs/reference" }
]
},
"$description": { "type": "string" }
}
}
}
Why this works: oneOf with three branches covers the object form, the array form and a whole-token alias. Using oneOf rather than anyOf means exactly one branch must match, which produces a clearer error when a value is malformed in a way that partially matches two branches — a common outcome with nested objects.
Step 4 — Model the typography set Link to this section
Intent: the same treatment for the other composite that appears in almost every system.
{
"typographyToken": {
"type": "object",
"required": ["$value", "$type"],
"properties": {
"$type": { "const": "typography" },
"$value": {
"type": "object",
"additionalProperties": false,
"required": ["fontFamily", "fontSize", "lineHeight"],
"properties": {
"fontFamily": { "oneOf": [{ "type": "string" }, { "$ref": "#/$defs/reference" }] },
"fontSize": { "oneOf": [{ "$ref": "#/$defs/dimension" }, { "$ref": "#/$defs/reference" }] },
"fontWeight": { "oneOf": [{ "type": "number", "minimum": 1, "maximum": 1000 },
{ "type": "string" }, { "$ref": "#/$defs/reference" }] },
"lineHeight": { "oneOf": [{ "type": "number", "exclusiveMinimum": 0 },
{ "$ref": "#/$defs/dimension" }, { "$ref": "#/$defs/reference" }] },
"letterSpacing": { "oneOf": [{ "$ref": "#/$defs/dimension" }, { "$ref": "#/$defs/reference" }] }
}
}
}
}
}
Why this works: requiring size and line height together enforces at the schema level what the paired typography rule enforces in CSS. Allowing lineHeight to be a number or a dimension is correct — both are legal — while exclusiveMinimum: 0 rejects a zero that would collapse every line box.
Step 5 — Dispatch by type with a conditional Link to this section
Intent: one schema that validates any token, applying the right rules based on $type.
{
"$id": "https://example.com/schemas/token.json",
"type": "object",
"required": ["$value", "$type"],
"allOf": [
{ "if": { "properties": { "$type": { "const": "shadow" } } },
"then": { "$ref": "#/$defs/shadowToken" } },
{ "if": { "properties": { "$type": { "const": "typography" } } },
"then": { "$ref": "#/$defs/typographyToken" } },
{ "if": { "properties": { "$type": { "const": "color" } } },
"then": { "properties": { "$value": {
"oneOf": [{ "$ref": "#/$defs/color" }, { "$ref": "#/$defs/reference" }] } } } }
]
}
Why this works: the if/then form dispatches without an exhaustive oneOf at the top level, which matters for error quality — a top-level oneOf across six token types reports six failures for one bad token, and the reader has to work out which branch was intended. The conditional form reports only the failures from the branch that matched the declared type.
Step 6 — Improve the error text Link to this section
Intent: Ajv’s default messages describe schema mechanics; contributors need to know what to change.
// scripts/validate/messages.mjs
const HUMAN = {
'/$defs/dimension': 'must be a length with a unit, e.g. "4px" or "0.5rem" (bare 0 is not valid)',
'/$defs/color': 'must be a hex colour, rgb() or oklch() value',
'/$defs/reference': 'must be a token reference in braces, e.g. "{color.brand.500}"',
};
export function humanise(error) {
const hint = Object.entries(HUMAN).find(([k]) => error.schemaPath.includes(k))?.[1];
const where = error.instancePath || '(root)';
return `${where} ${hint ?? error.message}`;
}
Why this works: the schema path already identifies which definition failed, so mapping a handful of paths to plain sentences covers most failures with very little code. A designer editing a token file should never have to read the phrase “must match exactly one schema in oneOf” to find out that they wrote 4 instead of 4px.
Verification Link to this section
Write fixtures for every branch and assert both directions. A valid single-layer shadow, a valid layered shadow, a shadow that is a whole-token reference — all must pass. A shadow with a misspelled field, one missing blur, one with a bare 0 offset, and one whose colour is an unquoted keyword — all must fail, with the message naming the field.
The direction that gets skipped is the positive one, and skipping it produces a schema that rejects legitimate tokens. The most common instance is an over-strict color pattern that rejects oklch() or an eight-digit hex with alpha, which then pushes contributors to work around the schema rather than fix their tokens.
Troubleshooting Link to this section
| Symptom | Likely cause | Fix |
|---|---|---|
| Valid layered shadows are rejected | The $value schema only models the object form |
Add the array branch to the oneOf |
| A typo in a field name passes validation | additionalProperties is not set to false |
Set it on every composite value object |
| One bad token produces six errors | A top-level oneOf across all token types |
Dispatch with if/then on $type instead |
| References inside composites fail | Field schemas accept only literals | Every field must accept the reference pattern as an alternative |
Errors mention oneOf and nothing useful |
Default Ajv messages | Map schema paths to plain sentences, as in step 6 |
Migration Note Link to this section
Adding composite schemas to an existing token set almost always surfaces inconsistency rather than error: several shadows written as strings, several as objects, and one as an array containing a single layer. All three may compile, because the compiler is more permissive than the specification.
Normalise before tightening the schema. Convert everything to the object or array form in one mechanical pass, verify the compiled CSS is byte-identical, and only then turn on strict validation. Tightening first produces a wall of failures on tokens that were working, which is the shape of change that gets reverted.
Keeping the Schema and the Compiler in Agreement Link to this section
A schema is a claim about what the compiler will accept, and the two drift apart in both directions. A schema stricter than the compiler rejects tokens that would have worked, which contributors experience as bureaucracy. A schema looser than the compiler admits tokens that fail later, at a stage where the error message is worse.
Two habits keep them aligned. The first is to derive the schema’s type list from the compiler’s supported types rather than from the specification — the specification describes what is standard, the compiler describes what will actually build. Where the two differ, the schema should follow the compiler and the gap should be recorded as a known limitation.
The second is to run the compiler over the schema’s own fixtures. Every fixture that the schema accepts should compile; every fixture it rejects should either fail to compile or produce output the team agrees is wrong. That second clause matters: a token the schema rejects and the compiler happily handles is a case where the schema is imposing a house rule rather than preventing a defect, and those are worth being deliberate about rather than accidental.
When a compiler upgrade lands, run the fixture set before anything else. A new version that has become more permissive is harmless; one that has become stricter will fail on tokens the schema still accepts, and finding that out from the fixtures is considerably better than finding it out from a broken build on a release day.
Related Link to this section
- JSON Schema Validation for Tokens — the parent area and the base schema this extends
- Validating Design Tokens Against JSON Schema in CI — the runner and error reporting these schemas feed
- @property Syntax Reference for Design Tokens — why composites are decomposed at the CSS layer