label files
why
a closed domain value carries one human meaning, and that meaning must have exactly one owner. enum switches inside components make the domain wording a distributed copy: every surface picks its own text, tone, and icon, and they drift. a label file turns "display meaning of a domain value" into a named, exhaustive decision the type checker enforces — an unmapped member is a compile error, not a silent fallback. one wording or tone change ships from one file and every surface changes together.
rule
a label file maps one closed domain value to its visual translation — a real
mapper, not a bag of UI strings. place it at
mappers/i18n/<domain>Label.ts, named for the exact enum-like domain, e.g.
mappers/i18n/processStatusLabel.ts.
it accepts the canonical enum value and returns the complete visual meaning: typed Paraglide message, optional tone, icon, accessibility description. a component asks the mapper for meaning; it never switches on enum members or holds translated labels.
scattered switches mean: adding a member requires finding every component that names it; the same member gets inconsistent wording across surfaces (dashboard "Processing" green, modal "In Progress" yellow, list "running" gray); a translation or tone change chases twenty surfaces instead of one mapper.
contract
| concern | owner |
|---|---|
| closed domain value | canonical enum-like object and schema. |
| human wording | typed Paraglide message in the locales workspace. |
| value-to-message decision | mappers/i18n/<domain>Label.ts. |
| presentation of the mapped result | component or display primitive. |
| business transition and authorization | service/domain workflow, never the label file. |
the mapper must be exhaustive: adding an enum member must fail type checking until its wording and visual meaning are chosen. no arbitrary strings, no default label, no falling back to the raw enum member in UI.
example shape
type ProcessStatusLabel = {message: ReturnType<typeof m.processStatusActive>;tone: "positive" | "neutral" | "danger";icon: Icon;};export function mapProcessStatusToLabel(status: ProcessStatus): ProcessStatusLabel {switch (status) {case ProcessStatusEnum.Active:return {message: m.processStatusActive(),tone: "positive",icon: PlayIcon,};case ProcessStatusEnum.Failed:return {message: m.processStatusFailed(),tone: "danger",icon: AlertIcon,};}}
directional translation, not aliasing: status stays the domain fact, the
returned value is the display contract. keep keys and message choices stable
enough for search; never manufacture them by casing or string replacement.
do not
- scatter
switchstatements, object lookups, or raw enum strings across components. - put domain labels in
components/ui/, route-private copy, or genericutilsfiles. - let a label mapper fetch, mutate, authorize, or infer a domain state.
- use a fallback that hides an unhandled enum member.
verification
- add a closed enum member; type checking must flag the label mapper as incomplete.
- search the enum members; the visual translation has one named owner.
- verify message, icon, tone, and accessible text for every member at its rendered surface.
- the label file imports messages from
locales, never a copied string table in an application.
good and bad
bad
<span>{status === ProcessStatusEnum.Active ? "active" : "failed"}</span>
the component becomes another owner of enum wording. every new surface re-decides tone and text, and an added enum member compiles silently while surfaces show the wrong thing.
good
const label = mapProcessStatusToLabel(status);return <StatusBadge tone={label.tone}>{label.message}</StatusBadge>;
one exhaustive mapper translates a domain value into a typed display contract; wording, tone, icon, and accessible text change from one file, and an unhandled member is a compile error before it reaches the screen.