Type Safety Is the Foundation of a Design System, Not a Feature of It

šÆ Hook
Most "design systems" are a component folder with good intentions.
Nothing stops the backend from renaming a field. Nothing stops a consumer from passing the wrong prop shape. Nothing stops history from sneaking in as a transitive dependency nobody asked for.
A real design system isn't a UI kit ā it's a typed contract that breaks the build the moment reality drifts from the interface. I built a working demo of exactly that for ReactJS Athens: a pnpm monorepo where one shared package defines the types once, and both the React app and the Express server are forced to agree with it.
š„ Hot Take
š¬ My take: Design tokens and Storybook get all the attention, but they're the easy 80%. The hard 20% ā and the part that actually prevents production bugs ā is making the shape of your data impossible to disagree on across the stack. If your frontend and backend each define
Productindependently, you don't have a design system. You have two guesses that happen to agree today.
š° Deep Dive: What's Actually in the Demo
1. One shared package, two consumers
packages/share-types is the single source of truth. It's a real pnpm workspace package (@demo/share-types), not a copy-pasted types.ts:
// packages/share-types/src/product.model.ts
export interface IProduct {
id: number
name: string
category: string
price: number
stock: number
rating: number
imageUrl: string
description?: string
}
apps/web and apps/server both depend on it as "@demo/share-types": "workspace:*" ā pnpm symlinks the package instead of installing a copy. Change the interface in one place, and both apps see the same type error on save.
TL;DR: Stop letting frontend and backend maintain parallel type definitions that quietly diverge. One package, two consumers, zero drift.
2. Zod schema generates the TypeScript type ā not the other way around
This is the piece that turns a compile-time type into a runtime guarantee:
// packages/share-types/src/product.schema.ts
export const ProductCreateSchema = z.object({
name: z.string().min(3, 'Name must be at least 3 characters'),
category: z.string().min(2, 'Category is required'),
price: z.number().positive('Price must be positive'),
stock: z.number().int().nonnegative('Stock must be non-negative integer'),
rating: z.number().min(0).max(5, 'Rating must be between 0 and 5'),
imageUrl: z.string().url('Must be a valid URL').optional().or(z.literal('')),
})
// Inferred, not hand-written ā schema and type can never disagree
export type ProductCreateInput = z.infer<typeof ProductCreateSchema>
The server uses this same schema to reject bad requests before they touch the database:
// apps/server/src/routes.ts
const validation = ProductCreateSchema.safeParse(req.body)
if (!validation.success) {
return res.status(400).json({
error: 'Validation failed',
details: z.treeifyError(validation.error),
})
}
My take: interface IProduct alone is a promise the compiler makes and the runtime can break ā anything can land in req.body. Deriving the TypeScript type from the Zod schema means validation logic and type definition physically cannot drift apart, because there's only one of them.
3. Component props typed from the shared package, React 19-style
// packages/share-types/src/ui-component.types.ts
export type ButtonVariant = 'primary' | 'secondary' | 'outline'
export type ButtonSize = 'small' | 'medium' | 'large'
export interface IButtonProps {
title: string
onClick: () => void
variant?: ButtonVariant
size?: ButtonSize
disabled?: boolean
className?: string
ref?: React.Ref<HTMLButtonElement>
type?: 'button' | 'submit' | 'reset'
}
// apps/web/src/components/Buttons.tsx
export const Button = ({
ref, title, onClick, variant = 'primary', size = 'medium',
disabled = false, className = '', type = 'button',
}: Readonly<IButtonProps>) => {
// ...variant/size style maps, then:
return <button ref={ref} type={type} onClick={onClick} disabled={disabled} aria-label={title}>{title}</button>
}
Notice ref is just a regular prop now ā no forwardRef wrapper. React 19 dropped that requirement, and it makes the props interface simpler to share: it's just an object shape, no special-cased typing for refs.
TL;DR: When the prop contract lives in the shared package, autocomplete and type errors show up identically whether you're editing the component or consuming it three folders away.
4. Phantom dependencies get caught, not silently allowed
pnpm's strict node_modules structure means a package only sees what it explicitly declares ā not whatever happens to be hoisted by a sibling dependency.
pnpm add express # from repo root
# ERR_PNPM_ADDING_TO_ROOT ā Running this command will add the dependency
# to the workspace root, which might not be what you want...
Try importing an undeclared transitive dependency (e.g. history, which react-router-dom pulls in) and you get a hard Cannot find module ā not a lucky resolution that breaks the day someone else's dependency tree shifts.
My take: npm and yarn's flat node_modules let this kind of bug hide for years. This isn't pnpm being strict for its own sake ā it's forcing your package.json to tell the truth about what you actually depend on.
š ļø The Monorepo Layout
robust-design-system-demo/
āāā apps/
ā āāā web/ ā React 19 + Vite + Tailwind
ā āāā server/ ā Express, validates with the shared Zod schema
āāā packages/
āāā share-types/ ā IProduct, ProductCreateSchema, IButtonProps
āāā design-tokens/ ā theme tokens, layout types
# pnpm-workspace.yaml
packages:
- "packages/*"
- "apps/*"
Build order isn't optional here ā TypeScript project references force share-types to build before web or server can compile against it. Change a type, forget to rebuild the package, and consumers still see the old type until you do. That's a deliberate tripwire, not a rough edge.
š” Dev Tip of the Week
If you're setting up a new shared-types package, don't hand-write both the interface and the validator. Pick one source of truth:
// Schema first, type derived ā the pattern from this demo
const Schema = z.object({ /* ... */ })
type Input = z.infer<typeof Schema>
Any time your validator and your interface are two separate hand-maintained things, one of them is going to be wrong within a few sprints. Not might ā will.
š¤ Community Question
ā Does your team share types between frontend and backend today ā a monorepo package, generated OpenAPI types, tRPC? Or are you still hand-syncing two definitions and hoping code review catches the drift?
š Where This Came From
This is the working demo behind my "Building Robust Design Systems in React with TypeScript" talk for ReactJS Athens ā pnpm workspaces, strict typing with generics, and Zod runtime validation, built live rather than slideware. Full talk outline and live-demo script are in my notes if you want the presentation flow, not just the code.
š Sources for This Issue
- Projects/OpenSource/ReactJs Athens ā full talk outline, abstract, and live-demo script
- Code:
robust-design-system-demo(pnpm monorepo āpackages/share-types,apps/web,apps/server) link-repo