Icon Cards from a Union Type, Guarded by the Compiler
A closed icon registry plus a required union key lets the TypeScript compiler keep card icons honest.
TL;DR
Icons hardcoded in JSX drift out of sync across typed content and locale files, silently breaking the UI. Moving icon choices into content data as a closed union type, backed by a small registry, makes the compiler reject mismatches at build time. Three files changed together, zero runtime cost, no silent failures.
The first time I looked closely at the corporate network section on our homepage, a single Building2 icon was doing duty for all seven company cards. Security services, holding, property, technology, consulting, construction, all wearing the same building glyph. One commit even deleted the icon outright, and the next commit brought it back as a bare glyph above the name, no card, no background. Those two moves made sense as a reaction: uniformly decorated cards read as stuck-on ornament. But the way out was not choosing between icons and no icons. It was differentiating the icon per business through data, and letting the TypeScript compiler keep everything in sync instead of manual discipline.
Placing icons in JSX is a dead end
The most natural idea at the time was simple. If every company needs its own icon, just drop the icon component straight into each card's JSX. Practical for seven cards, but the pattern is fragile. The icon choice now lives in the component while the company data lives in a strictly typed content module. Three places have to agree at all times: the content data, the type, and the render. Forget one, and what shows up is not an error. It is a card that silently renders without an icon, or every card turning identical again.
There is a second, less visible layer: the content data in this project is duplicated per locale. An icon decision per company has to be written into both the Indonesian and the English content file. If guarding that is a verbal agreement between developers, some file gets skipped eventually, and the strange part is that the page still renders without a single complaint. What this needs is not extra carefulness but a mechanism that refuses half-applied changes.
A closed registry, not wild strings
The fix: move the entire icon choice into the content module, then lock down the possibilities through the type. The component keeps only a small registry:
const ICONS = {
shield: Shield,
building: Building2,
home: Home,
"land-plot": LandPlot,
cpu: Cpu,
briefcase: Briefcase,
"hard-hat": HardHat,
} as const;
With as const, every key in ICONS has a literal type instead of a plain string. In content/types.ts, the company card gets a required field typed as a closed union [1]. Here is the shape:
icon:
| "shield"
| "building"
| "home"
| "land-plot"
| "cpu"
| "briefcase"
| "hard-hat";
Rendering is one line, const Icon = ICONS[company.icon]. That is an indexed access: the resulting type is looked up from the referenced property, and accessing a property that does not exist is a compile error, not an empty value in production [2].
The proof showed up when the two property companies needed to differ. A single commit added the land-plot union member, its registry entry, and the icon field in both locale data files at once. Three files changed in step in one push. Had any one of them been left out, the build would have failed before deploy. Compare that with the manual version: one commit forgets one content file, and nobody notices for months.
Three guarantees, active immediately
Once this structure stands, the compiler enforces three rules. First, keys in the content data must be union members, so a typo like landplot without the hyphen is rejected at compile time, not when the page starts looking odd. Second, every union member must have a registry entry, so removing the HardHat import while the union still says "hard-hat" gets called out. Third, the icon field itself is required, so no card is ever born without a visual identity.
The approach is also cheap. Lucide components are standalone, fully typed, and tree-shakable; only the icons you import reach the bundle [3]. I have no interest in swapping this for dynamic imports over a handful of static icons, that is overkill. A plain string map falls short too: it is too loose, and consistency depends on everyone's good habits. A union type gives validation at zero runtime cost, and for closed UI sets that is exactly the right price to pay.
Sources
[1] TypeScript Handbook — Everyday Types (union types)