Simple & type-safe localization in Rust
I wanted to reduce the number of dependencies in my Rust project, and I noticed that the rust-i18n crate has a lot of dependencies (a whopping 46 of them). Since I didn’t really have that many translations and they didn’t need any special logic, I figured that I could just write a very simple localization function myself.
First, I made an enum in a new file lang.rs to represent all supported languages (just two of them in my case):
enum Lang {
En,
Fi,
}
Then, I made another, with a variant for each translation key:
#[derive(Clone, Copy)]
#[allow(non_camel_case_types)]
pub enum Key {
battery_remaining,
no_adapter_found,
view_logs,
view_updates,
quit_program,
device_charging,
device_disconnected,
version,
}
pub fn t(key: Key) -> &'static str {
use Key::*;
match *LANG {
Lang::En => match key {
battery_remaining => "remaining",
no_adapter_found => "No headphone adapter found",
view_logs => "View logs",
view_updates => "View updates",
quit_program => "Close",
device_charging => "(Charging)",
device_disconnected => "(Disconnected)",
version => "Version",
},
Lang::Fi => match key {
battery_remaining => "jäljellä",
no_adapter_found => "Kuulokeadapteria ei löytynyt",
view_logs => "Näytä lokitiedostot",
view_updates => "Näytä päivitykset",
quit_program => "Sulje",
device_charging => "(Latautuu)",
device_disconnected => "(Ei yhteyttä)",
version => "Versio",
},
}
}
Just two nested match statements, both using integers as the underlying value. Super efficient. If a new language or translation key is added, the compiler will enforce that we also add them to this function.
You might be wondering what *LANG is? Here it is:
use std::sync::LazyLock;
static LANG: LazyLock<Lang> = LazyLock::new(|| {
let locale = &sys_locale::get_locale().unwrap_or("en-US".to_owned());
match locale.as_str() {
"fi" | "fi-FI" => Lang::Fi,
_ => Lang::En,
}
});
This LazyLock will be initialized on startup. It gets the system locale using the sys_locale crate. If a matching locale is not found, it will default to English.
It can now be used like this:
use lang::Key::*;
let menu_close = MenuItem::new(lang::t(quit_program), true, None);
What if you want to do something more complex, like format a number? The solution above won’t work because the function t returns a static string. We need a new enum and a new function:
#[derive(Clone, Copy)]
#[allow(non_camel_case_types)]
pub enum DynKey {
connected_devices(isize),
}
pub fn td(key: DynKey) -> String {
use DynKey::*;
match *LANG {
Lang::En => match key {
connected_devices(count) =>
format!("{} connected device{}", count, if count == 1 {"s"} else {""}),
},
Lang::Fi => match key {
connected_devices(count) =>
format!("{} yhdistetty{} laite{}", count,
if count == 1 {""} else {"ä"},
if count == 1 {""} else {"tta"},
),
},
}
}
This will return a String that is allocated at runtime.
I made this change in my project and was able to remove the dependency on rust-i18n and also take advantage of the compile-time safety of enums. A win-win situation for me. Of course, larger projects will need much better tooling around writing translations, and having them written as Rust code is not a great idea, but for simple projects, I like to keep it simple.