FormatJS

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.

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.