n-driven composition. The philosophy is strict at the edges, loose in the middle. Input validation and output emission are enforced; composition patterns, file organization, and helper granularity remain developer-controlled.
Step 1: Define an Opaque Measurement Type
CSS values are meaningless without units. String concatenation (${value}px) bypasses type checking and enables silent unit collisions. The solution is an opaque measurement object that tracks both magnitude and unit at the type level.
// src/css/measure.ts
export type Unit = 'px' | 'rem' | 'em' | '%';
export interface Measurement<T extends Unit = Unit> {
readonly value: number;
readonly unit: T;
readonly _brand: unique symbol;
}
export function measure<T extends Unit>(value: number, unit: T): Measurement<T> {
return { value, unit, _brand: Symbol() } as Measurement<T>;
}
The _brand property prevents TypeScript from treating different measurement types as interchangeable. This is the foundation of compile-time safety.
Step 2: Implement Type-Safe Math Operations
Measurements must support composition without losing type information. Arithmetic operations should only allow compatible units.
// src/css/math.ts
import { Measurement, Unit } from './measure';
export function addMeasurements<A extends Unit, B extends Unit>(
a: Measurement<A>,
b: Measurement<B>
): Measurement<A> {
if (a.unit !== b.unit) {
throw new TypeError(`Unit mismatch: cannot add ${a.unit} to ${b.unit}`);
}
return measure(a.value + b.value, a.unit);
}
export function scaleMeasurement<T extends Unit>(
m: Measurement<T>,
factor: number
): Measurement<T> {
return measure(m.value * factor, m.unit);
}
Mismatched units fail at compile time in TypeScript and throw explicit errors at runtime during development. This eliminates the px + deg or rem + % bugs that silently break layouts.
Step 3: Build Helper Functions for CSS Properties
Helpers consume measurements and emit typed style objects. They decouple design intent from raw CSS syntax.
// src/css/helpers/spacing.ts
import { Measurement } from '../measure';
interface SpacingConfig {
uniform?: Measurement;
block?: Measurement;
inline?: Measurement;
}
export function createSpacingConfig(config: SpacingConfig): Record<string, string> {
const output: Record<string, string> = {};
if (config.uniform) {
output.padding = `${config.uniform.value}${config.uniform.unit}`;
output.margin = `${config.uniform.value}${config.uniform.unit}`;
} else {
if (config.block) {
output.paddingBlock = `${config.block.value}${config.block.unit}`;
output.marginBlock = `${config.block.value}${config.block.unit}`;
}
if (config.inline) {
output.paddingInline = `${config.inline.value}${config.inline.unit}`;
output.marginInline = `${config.inline.value}${config.inline.unit}`;
}
}
return output;
}
The helper accepts a structured configuration, validates it against the type system, and returns a plain object ready for injection into a style sheet or CSS-in-JS runtime.
Step 4: Integrate Design Tokens as the Single Source of Truth
Tokens drive the system. Call sites remain invariant; design changes happen exclusively in the token layer.
// src/tokens/spacing.ts
import { measure } from '../css/measure';
export const spacing = {
sm: measure(4, 'px'),
md: measure(8, 'px'),
lg: measure(16, 'px'),
xl: measure(24, 'px'),
};
// src/tokens/borders.ts
import { measure } from '../css/measure';
export const borders = {
default: {
width: measure(1, 'px'),
radius: measure(4, 'px'),
},
accent: {
width: measure(2, 'px'),
radius: measure(8, 'px'),
},
};
Step 5: Compose at the Component Boundary
Components import tokens and pass them to helpers. The helper emits the final CSS object.
// src/components/Card.styles.ts
import { createSpacingConfig } from '../css/helpers/spacing';
import { spacing, borders } from '../tokens';
export const cardStyles = {
...createSpacingConfig({ uniform: spacing.md }),
borderWidth: `${borders.default.width.value}${borders.default.width.unit}`,
borderRadius: `${borders.default.radius.value}${borders.default.radius.unit}`,
};
When design requests a thicker accent border, you update tokens/borders.ts. The component file remains untouched. The helper re-emits the updated CSS object during the next build. This inversion of control is the core architectural advantage: markup and component logic become stable, while the design system drives all visual mutations.
Architecture Rationale
- Opaque measurements prevent string interpolation bugs and enable TypeScript to catch unit mismatches before runtime.
- Helper functions abstract CSS property syntax, making the system resilient to spec changes. If
padding-block replaces legacy padding shorthand, only the helper updates.
- Token-driven composition ensures a single source of truth. It eliminates class-string duplication and makes design reviews deterministic.
- Strict edges, loose middle means the framework validates inputs and guarantees output shape, but does not dictate file structure, naming conventions, or component composition patterns. This preserves developer autonomy while enforcing safety.
Pitfall Guide
1. Unit Collision Blindness
Explanation: Developers mix px, rem, and % in the same calculation without explicit conversion, causing layout shifts across viewport sizes or font scaling.
Fix: Enforce a base unit (typically rem for accessibility) and require explicit cast functions like pxToRem() before arithmetic. The type system should reject cross-unit operations.
2. Helper Over-Engineering
Explanation: Creating helpers for every CSS property leads to maintenance overhead and framework lock-in. Most properties (e.g., cursor, pointer-events) rarely need type safety.
Fix: Focus helpers on high-frequency, design-system-critical properties: spacing, borders, typography, shadows, and layout containers. Pass through low-frequency properties as raw strings.
3. Runtime Validation in Production
Explanation: Leaving unit mismatch checks and type assertions in production bundles increases bundle size and execution time.
Fix: Use TypeScript's static analysis for development. Strip runtime checks in production via build configuration or conditional compilation. Rely on the compiler, not the runtime, for safety.
4. Token Drift
Explanation: Design tokens diverge from actual rendered values due to manual overrides, CSS specificity wars, or AI-generated inline styles.
Fix: Implement a token sync script that runs during CI. It extracts computed styles from a headless browser and compares them against token definitions. Flag mismatches as build failures.
5. AI Prompt Leakage
Explanation: AI coding assistants generate raw CSS strings or arbitrary utility values that bypass helpers, reintroducing the exact problems the framework solves.
Fix: Configure ESLint rules to flag raw CSS strings in style objects. Enforce a no-raw-css policy that requires all values to pass through approved helpers. Train AI prompts to reference helper signatures explicitly.
6. Spec Coverage Gaps
Explanation: Assuming the framework must model every CSS property leads to lag when new spec features land. Developers wait for library updates instead of adopting @container or view-timeline.
Fix: Design the emission layer to accept a passThrough object. Known properties are validated; unknown properties are forwarded directly to the output. This guarantees spec compliance without framework dependency.
7. Build-Time vs Runtime Confusion
Explanation: Mixing static token resolution with dynamic runtime theming causes hydration mismatches and inconsistent server/client rendering.
Fix: Separate static design tokens (resolved at build time) from dynamic theme tokens (resolved at runtime). Use CSS custom properties for runtime theming, and keep the type-safe layer focused on static, predictable styling.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small team, rapid prototyping | Utility-first with strict linting | Faster initial setup, lower abstraction overhead | Low upfront, high maintenance at scale |
| AI-heavy development workflow | Type-safe token framework | Prevents AI from generating invalid or off-scale CSS | Moderate setup, drastically reduces review overhead |
| Enterprise design system | Token-driven helpers with CI sync | Guarantees consistency across dozens of teams | High initial investment, near-zero drift cost |
| Legacy migration | Gradual helper adoption + CSS modules | Avoids full rewrite, isolates new patterns | Medium effort, phased risk reduction |
Configuration Template
// styleguard.config.ts
import { defineConfig } from '@styleguard/core';
export default defineConfig({
baseUnit: 'rem',
validation: {
strictUnits: true,
allowPassThrough: true,
stripRuntimeChecks: process.env.NODE_ENV === 'production',
},
helpers: {
spacing: {
enabled: true,
axisAware: true,
},
borders: {
enabled: true,
shorthand: 'modern', // uses border-block, border-inline
},
typography: {
enabled: true,
fluid: false,
},
},
tokens: {
source: './src/tokens',
sync: {
enabled: true,
browser: 'chromium',
viewport: [375, 768, 1440],
},
},
ai: {
enforceHelpers: true,
promptTemplate: 'Use spacing.md, borders.default, etc. Do not use raw CSS strings.',
},
});
Quick Start Guide
- Initialize the measurement layer: Create
src/css/measure.ts with the opaque Measurement<T> type and measure() constructor. Export it as the single source for all CSS values.
- Generate helpers: Run
npx styleguard init helpers to scaffold spacing.ts, borders.ts, and typography.ts with type-safe emission logic.
- Define tokens: Create
src/tokens/index.ts and populate it with measure() calls for spacing, radii, and weights. Export as a flat or namespaced object.
- Wire components: Replace raw CSS objects with helper calls:
createSpacingConfig({ uniform: tokens.spacing.md }). Commit and verify TypeScript catches unit mismatches.
- Enable CI validation: Add
styleguard sync --check to your pipeline. The script will compare computed styles against tokens and fail the build on drift.
This architecture transforms CSS from a fragile string layer into a compiled, verifiable contract. It survives AI generation, scales with team size, and adapts to spec evolution without framework lag. The middle remains yours to organize; the edges are now enforced.