formatjs_intl is the application-level Rust runtime. It combines message
descriptors, translation catalogs, ICU4X locale negotiation, and a shared
compiled-message cache.
Installation#
[dependencies]
formatjs_intl = "0.1"
formatjs_icu_messageformat = "0.1"
serde_json = "1"
The second dependency provides Value and Values for runtime arguments.
serde_json loads optional precompiled catalogs.
Create a catalog and format a message#
use formatjs_icu_messageformat::{Value, Values};
use formatjs_intl::{Intl, IntlCache, MessageCatalog, MessageDescriptor, message_descriptor};
use std::{collections::HashMap, sync::Arc};
const TASKS: MessageDescriptor = message_descriptor!(
id: "tasks.count",
default_message: "{count, plural, one {# task} other {# tasks}}",
description: "Task count"
);
fn main() -> Result<(), formatjs_intl::Error> {
let mut catalog = MessageCatalog::new();
catalog.insert("en", HashMap::new())?;
catalog.insert(
"fr",
HashMap::from([(
"tasks.count".to_owned(),
"{count, plural, one {# tâche} other {# tâches}}".to_owned(),
)]),
)?;
let catalog = Arc::new(catalog);
let cache = Arc::new(IntlCache::new());
let intl = Intl::try_new(["fr-CA", "fr"], "en", catalog, cache)?;
let values: Values =
HashMap::from([("count".to_owned(), Value::from(2_i64))]);
assert_eq!(intl.locale().to_string(), "fr");
assert_eq!(intl.format_message_to_string(TASKS, &values)?, "2 tâches");
Ok(())
}
The default locale must have a catalog, but that catalog may be empty. Message lookup falls back in this order:
- selected locale catalog
- default locale catalog
- descriptor
default_message
Request-scoped locales#
Build MessageCatalog and IntlCache once, then create a lightweight Intl
for each request:
let intl = Intl::try_new(
requested_locales,
"en",
catalog.clone(),
cache.clone(),
)?;
Requested locales must already be ordered by preference. ICU4X performs locale
fallback against available catalogs, such as fr-CA to fr. Parsing an HTTP
Accept-Language header remains the responsibility of the application or web
framework adapter.
Descriptors and IDs#
message_descriptor! accepts compile-time string literals:
const GREETING: MessageDescriptor = message_descriptor!(
default_message: "Hello, {name}!",
description: "Greeting shown after sign-in"
);
Without an explicit id, it generates
[sha512:contenthash:base64:10] from default_message and description. The
native FormatJS CLI recognizes the same macro and emits the same ID:
formatjs extract "src/**/*.rs" --out-file messages.json
Use id: "greeting" when a stable semantic ID is preferred.
Inline formatting#
format_message! creates an extractable descriptor and formats it in one
expression. values is optional:
use formatjs_intl::format_message;
let title = format_message!(
&intl,
default_message: "Approve to continue",
description: "Approval card title",
);
let greeting = format_message!(
&intl,
default_message: "Hello, {name}!",
description: "Greeting shown after sign-in",
values: &values,
);
The macro returns String. It uses normal catalog and default-message fallback.
If cache infrastructure prevents formatting, it reports the error through
with_on_error and returns default_message verbatim. Use
format_message_to_string when explicit Result handling is required.
Typed presentation text#
Use formatted_message! when an interface should accept formatted text rather
than arbitrary strings. It returns formatjs_intl::FormattedMessage and uses
the same extraction, IDs, locale negotiation, fallback, and with_on_error
reporting as format_message!. There is no raw-string constructor.
Untranslated content must come from an application-owned type implementing
VerbatimSource. Implement that trait beside the domain type's controlled
constructors, not on a generic string wrapper or diagnostic error.
use formatjs_intl::{Intl, FormattedMessage, Verbatim, VerbatimSource, formatted_message};
struct SelectedFile {
name: String,
}
impl VerbatimSource for SelectedFile {
fn as_verbatim(&self) -> &str {
&self.name
}
}
fn render(intl: &Intl, file: &SelectedFile) -> FormattedMessage {
formatted_message!(
intl,
default_message: "Open {path}?",
description: "Confirmation before opening a selected file",
values: { path: Verbatim::new(file) },
)
}
Verbatim is interpolation-only: it cannot convert directly to
FormattedMessage. Strings, paths, and arbitrary Display values do not
implement VerbatimSource, so Verbatim::new(&error.to_string()) does not
compile. Applications remain responsible for reviewing their domain types and
trait implementations; this is a type-level guard, not a security sandbox.
Other replacements accept nested FormattedMessage values (including borrowed
messages), numbers, booleans, and formatjs_icu_messageformat::DateTimeValue.
For reusable replacement maps, use MessageValues and pass values: &values.
For static descriptors, Intl::format_message_typed returns
Result<FormattedMessage> and format_message_typed_or_default also recovers
infrastructure failures with the descriptor default. Read the result with
as_str() or consume it with into_string() at the final presentation step.
Migration: FormattedMessage::verbatim(...) has been removed. Move permitted
untranslated sources behind domain-owned VerbatimSource implementations and
interpolate them with Verbatim::new(&source) in an authored message. Do not
replace the old constructor with a catch-all string wrapper or a "{detail}"
message that forwards diagnostics.
The type records a formatting path; it does not guarantee translation coverage,
successful interpolation, HTML escaping, or sanitized content. It is separate
from the existing low-level formatjs_icu_messageformat::FormattedMessage<T>
rich-text result. Existing string and rich-text formatting interfaces remain
unchanged.
Shared cache#
IntlCache stores parsed messages by source string. Share one Arc<IntlCache>
across request-scoped Intl values to avoid reparsing translations and default
messages.
Precompiled catalogs#
Compile translations to FormatJS AST JSON to remove runtime message parsing:
formatjs compile translations/fr.json --out-file translations/fr.compiled.json --ast
Deserialize that output and insert it directly:
use formatjs_intl::{MessageCatalog, PrecompiledMessages};
let messages: PrecompiledMessages =
serde_json::from_str(include_str!("../translations/fr.compiled.json"))?;
let mut catalog = MessageCatalog::new();
catalog.insert_precompiled("fr", messages)?;
These messages are ready to format when inserted and bypass both runtime
parsing and IntlCache.
See the complete formatjs_intl API.
ICU argument types#
Inline format_message! and formatted_message! values are checked against
every ICU occurrence before conversion to runtime values. Number/plural arguments
require numbers; date/time arguments accept timestamps or DateTimeValue;
select arguments require selector-compatible values. Parsing handles quoting,
rich tags, and nested branches.
format_message! accepts rich tags as callbacks returning runtime parts:
values: {b: |parts| Ok(parts)}. The localized-output formatted_message!
interface retains its restriction against rich callbacks and unchecked strings.
This is a breaking change for inline values already erased to Value, including
Value::tag(...). Pass the original scalar or callback instead. Explicit
values: &values maps retain runtime validation. Reusable descriptors and ID-only
calls do not yet carry argument contracts. Value ranges and translated catalog
compatibility still require runtime checks.