Simple & type-safe localization in Rust

Published 11.05.2025 • Last modified 17.04.2026

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.