Skip to content

Code lists — shared, translatable vocabularies

Country, currency and unit of measure are code lists: shared, translatable, code-keyed vocabularies that many business objects reference.

They are deliberately not enum fields. An enum models one business object’s own ad-hoc option set — an invoice’s payment status, a movement’s direction. A code list is external vocabulary that outlives any single object and needs the same codes and the same translations everywhere it appears.

A code list is a Catalog, defined once in @onerp/catalogs, and reached through the generic field builder:

import { countryCatalog } from "@onerp/catalogs";
country: field.codeList(countryCatalog, { label: "Country" }),

The field stores the code in the column, validates it against the catalog, and renders as a searchable dropdown showing the translated label. Its type is the catalog’s code union — CountryCode | null above, and CountryCode only where the field says required: true, exactly like every other kind.

Labels come from the catalog’s labels map, which is locale-complete: every code has a label in every locale the catalog declares — TypeScript enforces it. codeListLabel(catalog, code, locale) from @onerp/meta reads one and throws on a code the catalog does not carry; useCodeListLabel(catalog, code) from @onerp/react/meta reads it in the active locale. A label is never re-declared per field, so two business objects referencing country cannot drift apart on what they call Belgium.

@onerp/catalogs ships three: countryCatalog (ISO 3166-1 alpha-2, machine-generated — regenerate it, never hand-edit it), currencyCatalog (ISO 4217), and unitCatalog (UN/ECE Rec 20 — every unit a quantity can be denominated in). A fourth — incoterms, say — is three moves in that package and no framework change, since field.codeList is generic over any Catalog. The order is what makes the code union survive: a bare Catalog infers string codes, and every field using it goes with them.

import type { Catalog } from "@onerp/meta";
const codes = ["EXW", "FCA", "DAP"] as const;
export type Incoterm = (typeof codes)[number];
export const incotermCatalog: Catalog<Incoterm> = {
id: "incoterm",
codes,
labels: {
en: { EXW: "Ex Works", FCA: "Free Carrier", DAP: "Delivered at Place" },
},
};

unitCatalog is the one catalog whose codes mean something arithmetic, so @onerp/catalogs publishes unitMeasures beside it: each code’s dimension and its factor within that dimension, as strings. The catalog is the vocabulary, the record is what a rule computes with, and the two extend together — Record<UnitCode, …> makes TypeScript refuse a code added to one and not the other.

import { convertibleUnits, sharesDimension, unitMeasures } from "@onerp/catalogs";

sharesDimension(a, b) is the whole conversion rule — quantities never cross a dimension — and convertibleUnits(base) is the picker’s list. Both are pure and browser-safe, which is why the unit picker in apps/web needs no round trip to ask what a product’s line may be denominated in. The conversion itself lives with the domain, in @onerp/master/unit.