ADR-022: Generated Catalogs Use Executable Message Functions
Generated catalogs previously exposed only ICU strings, so the browser parsed each dynamic message on first use. Attaching a second map of pre-parsed nodes would remove parsing, but it would duplicate every dynamic message key and keep an AST interpreter in the browser hot path.
Status: Accepted Date: 2026-08-01 Revised: 2026-08-11
Context
Palamedes needs one generated representation that handles plain strings, variables, formatters, plurals, selects, and rich-text tags across the Core, React, and Solid renderers. Hand-written string catalogs must remain supported without requiring a build step.
Decision
Generated catalog modules export one branded message map:
- constant messages are string values
- dynamic messages are executable functions
- invalid or unsupported messages are functions that delegate to the existing lazy string parser, preserving diagnostics and fallback behavior
Message functions receive the lookup values and a small renderer interface.
They call named operations such as value, number, plural, tag, and
join; they do not return framework-specific values directly. Plural and
select branch functions and branch tables are hoisted to module scope so they
are allocated once rather than once per render.
Core, React, and Solid provide renderer implementations for their result types. The same function therefore produces a string, React nodes, or Solid nodes. Generated modules contain no parallel string/AST maps.
The catalog brand distinguishes generated constant strings from hand-written ICU strings. An unbranded string catalog retains bounded lazy parsing. A copied catalog loses the constant-string brand but remains correct; dynamic function entries are still executable.
Ferrocat remains the Rust-side ICU parser. The Rust core lowers its AST into a host-neutral message program, and the Node host boundary renders safe JavaScript module source in accordance with ADR-011.
The native renderer is the single catalog-module generator. The public
TypeScript renderCatalogModule() compatibility helper delegates to that
renderer instead of parsing ICU and maintaining a second JavaScript generator.
Alternatives Considered
1. Parallel string and pre-parsed-node maps
Rejected because it duplicates keys and dynamic message data, allocates AST objects during module evaluation, and retains an interpreter in every runtime renderer.
2. Replace message strings with serialized AST values
Rejected because it still requires runtime interpretation and makes even constant messages structured data.
3. Generate framework-specific functions
Rejected because it would duplicate compiler semantics across Core, React, and Solid. The renderer parameter keeps one compiler output portable across hosts.
4. Remove support for hand-written string catalogs
Rejected because small applications and runtime-loaded catalogs still benefit from the existing lazy parser. Generated catalogs take the optimized path; manual catalogs remain a supported fallback path.
Consequences
- Generated dynamic messages execute directly without browser ICU parsing or AST traversal.
- Generated catalog values are
string | CompiledMessage; consumers must not assume every generated value is JSON-serializable. - Public
getMessageNodes()remains available by parsing a reconstructed pattern on explicit calls, but first-party rendering neither parses ICU nor allocates nodes for compiled messages. - The compiler owns safe JavaScript expression generation, including escaped literals, computed object keys, nested choices, and plural-pound semantics.
- Custom integrations loading generated modules must declare
@palamedes/coreas a direct runtime dependency because the module importsdefineCompiledCatalog()from itscompiledentrypoint. - Runtime and bundle benchmarks must cover both payload size and first-render execution before the representation is considered stable.
- ADR-023 defines the parser-free package boundary used by generated production code while retaining package-root compatibility for hand-written catalogs.