Your Fluent translations as typed Rust functions. A misspelled key, a missing translation, or the wrong argument type becomes a compile error — and a translation you no longer use becomes a build warning.
Write a message in an .ftl file:
# locales/en/main.ftl
# $name (String) - the user's name
hello = Hello { $name }…and fluent-typed generates a typed accessor for it. Calling it wrong no longer fails
silently at runtime — it fails the build:
let strs = L10n::En.load();
strs.msg_hello("Sam"); // ✓ compiles
strs.msg_hello(42); // ✗ wrong argument type — caught at compile time
strs.msg_helo("Sam"); // ✗ misspelled key — caught at compile time- Typed accessors, no boilerplate. One
build.rscall turns every message into a typed function. Argument types come from comments,NUMBER()calls and plural selectors. - Dead translations become warnings. An unused message accessor raises a compiler warning, so stale keys don't quietly accumulate.
- Cross-locale safety. An accessor is generated only for a message present in every
locale with a matching set of variables, and whose references (other messages, terms,
functions) resolve everywhere — the rest are skipped with a warning naming the
.ftlfile and line. - A linter for comment mistakes. Typo'd type keywords, annotations of variables that don't exist, comments detached from their message.
- Embed or load on demand. Bake every locale into the binary, or load one language at a time — on a server, or downloaded by a client.
- Validated runtime loading. Load an
.ftlthe build never saw — a language added after release, or a hot-reloaded translation — checked against the compiled message contract, so a missing message, a wrong variable or a broken reference is rejected when you load it instead of panicking in an accessor. - Language negotiation.
L10n::langneg("fr-CA, en;q=0.5")picks an available language from anAccept-Languageheader, falling back to your default. - Bidi-safe by default. Interpolated values are wrapped in Unicode isolation marks so right-to-left text can't corrupt the surrounding message.
The established way to get compile-time-checked Fluent in Rust is
i18n-embed with its
i18n-embed-fl fl! macro, which checks a
message id and its arguments where you call it. fluent-typed takes a different route: a
build script generates one typed Rust function per message.
| fluent-typed | fl! macro (i18n-embed-fl) |
|
|---|---|---|
| Call site | strs.msg_hello("Sam") — a real method |
fl!(LOADER, "hello", name = "Sam") |
| Editor support | autocomplete, go-to-definition, rename | string ids, no completion |
| Unused translations | compiler warning for messages you dropped | not detected |
| Argument types | inferred & enforced (String/Number/…) |
checked as present |
| Output | plain Rust you can read & commit | macro-expanded at the call site |
Trade-offs, honestly: fluent-typed runs a build script and commits a generated file,
where i18n-embed has no codegen step; i18n-embed has a much larger ecosystem and
user base; and fluent-typed needs a recent toolchain (see
Versioning and MSRV). Pick fluent-typed when you want
translations to feel like ordinary typed functions and want dead keys to surface
themselves.
1. Add the dependency twice — once for the generated code at runtime, once for the build script:
# Cargo.toml
[dependencies]
fluent-typed = "0.10"
[build-dependencies]
fluent-typed = { version = "0.10", features = ["build"] }2. Generate the code in build.rs:
// build.rs
use fluent_typed::{build_from_locales_folder, BuildOptions};
fn main() -> std::process::ExitCode {
build_from_locales_folder(BuildOptions::default())
}With the defaults, this reads locales/, writes the Rust code to src/l10n.rs, and
joins all translations into gen/translations.ftl, which the generated code embeds in
your binary. Use try_build_from_locales_folder instead if you want a Result. See
BuildOptions
for every option.
3. Write your translations in locales/<language>/*.ftl, one folder per language:
# locales/en/main.ftl
language-name = English
greeting = Welcome!
# $name (String) - the user's name
hello = Hello { $name }
unread-messages = { $count ->
[one] You have one message
*[other] You have { $count } messages
}
login = Log in
.placeholder = Enter your emailAdd the same messages to locales/fr/main.ftl, locales/de/main.ftl, and so on.
Argument types are read from the default locale only, en unless you change it (see
Types and linting).
4. Use the generated code:
// src/main.rs (or lib.rs)
mod l10n;
use l10n::{L10n, L10nLanguage};let strs: L10nLanguage = L10n::En.load();
assert_eq!(strs.msg_greeting(), "Welcome!");
let hello: String = strs.msg_hello("Sam"); // a String argument
let unread: String = strs.msg_unread_messages(3); // a number (the plural selector)
let placeholder = strs.msg_login_placeholder(); // a message attributeEvery message becomes a method named after it, with a msg_ prefix (configurable with
BuildOptions::with_prefix). L10n is an enum of your languages: L10n::iter() lists
them, and when every locale has a language-name message,
L10n::Fr.language_name() gives you the label for a language menu.
Interpolated values are wrapped in bidi isolation marks, so
helloabove is"Hello \u{2068}Sam\u{2069}". That's the safe default for UI text; see Bidi isolation to turn it off.
To get the unused-message warnings, generate the file in the crate that uses the accessors, and keep the generated types out of that crate's public API — otherwise every message looks "used".
- Output modes — embed all languages, compress them, or load one language at a time on demand (for example in a WASM client).
- Languages and negotiation
— pick a language from an
Accept-Languageheader, and serve all languages from one server. - Loading translations at runtime — add a language after release, hot-reload a translation, validate a translator's file in CI.
- Types and linting — how argument types are decided, and the checks for mistakes in type comments.
- Structured messages and bidi — put a link or an icon in the middle of a sentence, and control bidi isolation.
The guides are also on docs.rs.
The minimum supported Rust version is 1.88 (the crate uses the 2024 edition and let-chains). Raising the MSRV is treated as a minor-version change.
fluent-typed is pre-1.0 and its API is still settling — minor releases may contain
breaking changes, each called out in the
CHANGELOG. The runtime
re-exports of fluent-bundle types (FluentArgs, FluentValue, FluentNumber)
track that crate's own (pre-1.0) releases. Depend on a minor version
(fluent-typed = "0.10") and read the changelog before upgrading.