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

concernowner
closed domain valuecanonical enum-like object and schema.
human wordingtyped Paraglide message in the locales workspace.
value-to-message decisionmappers/i18n/<domain>Label.ts.
presentation of the mapped resultcomponent or display primitive.
business transition and authorizationservice/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 switch statements, object lookups, or raw enum strings across components.
  • put domain labels in components/ui/, route-private copy, or generic utils files.
  • let a label mapper fetch, mutate, authorize, or infer a domain state.
  • use a fallback that hides an unhandled enum member.

verification

  1. add a closed enum member; type checking must flag the label mapper as incomplete.
  2. search the enum members; the visual translation has one named owner.
  3. verify message, icon, tone, and accessible text for every member at its rendered surface.
  4. 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.

search pages

go to any page