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:

  1. selected locale catalog
  2. default locale catalog
  3. 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.