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.
External ICU data#
Existing APIs keep compiled ICU data enabled by default. To supply your own locale data, disable default features and create one shared context:
[dependencies]
formatjs_intl = { version = "2", default-features = false }
icu_provider_blob = { version = "2.1", features = ["alloc"] }
One context supplies all formatters and locale fallback. Requests such as
en-XX or en-XX-u-foo-x-private fall back to available en data. Catalog APIs
stay unchanged; reuse the context across requests to share cached messages.
Add French to an English app#
Generate English and French data with a matching ICU4X datagen version, keeping only the markers your release binary uses:
cargo build --release
icu4x-datagen --markers-for-bin target/release/my-app --locales en fr --format blob --out locale-data.postcard --overwrite
Replace my-app with your binary. If it embeds the blob with include_bytes!,
build once against a placeholder blob, generate, then rebuild. --markers all
is the alternative when there is no binary yet: it needs datagen's default
features (use_wasm, or use_icu4c) and produces a far larger blob. Load it
once, then add application translations:
use formatjs_intl::{Intl, IntlContext, MessageCatalog, format_message};
use icu_provider_blob::BlobDataProvider;
use std::{collections::HashMap, sync::Arc};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let bytes = std::fs::read("locale-data.postcard")?;
let provider = BlobDataProvider::try_new_from_blob(bytes.into_boxed_slice())?;
let context = IntlContext::try_with_provider(provider)?;
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(),
)]),
)?;
context.check_catalog(&catalog)?;
let intl = Intl::try_new_with_context(["fr"], "en", Arc::new(catalog), &context)?;
let label = format_message!(
&intl,
id: "tasks.count",
default_message: "{count, plural, one {# task} other {# tasks}}",
values: { count: 2_i64 },
);
assert_eq!(label, "2 tâches");
Ok(())
}
All formatters now have French data. For another locale, regenerate the blob
including existing locales, add translations, and recreate the context.
Missing provider data follows normal error handling, with one exception: a
catalog locale the blob lacks entirely falls back to root (und) data and
formats without any error. Compiled data is never used as a fallback.
IntlContext::check_catalog validates every formatter at startup, naming the
locale and formatter when language-specific data resolves to root. This also
catches partially missing date/time or plural data. Regional fallback, region
defaults, global data, and explicit und remain valid. Runtime fallback is
unchanged. Generate blobs with --deduplication none or
retain-base-languages; maximal removes root-identical locale data that this
check requires.
Without compiled_data, use Intl::try_new_with_context, and
IntlContext::negotiate_locale in place of the standalone negotiate_locale;
default caches are unavailable. Disable defaults on direct
formatjs_icu_messageformat dependencies too: Cargo unifies dependency features.