Type-Safe Internationalization in Gleam

mooreds3 pts0 comments

Type-safe i18n in Gleam - Markus EliassonType-safe i18n in Gleam<br>I wanted to add translations to a small Gleam project I am working on. The type-safety and pattern matching in Gleam helped me make a solution that I am pretty happy with.<br>23 April, 2026I am working on a small web application in Gleam that I want to prepare with translations. The application itself is<br>a small social network1 centered around the art of album listening and discussing music between friends.<br>1. Sounds fancy right? It's a bit of a stretch, but it is a good excuse to buy a domain name and hack on Gleam.

The primary language will be Swedish since it feels pretentious to have an English interface when we discuss in Swedish.<br>But I hope to open source the application if it turns out OK, so I want to have translations in place from the start.

My experience with translations in other projects has all been based on the premise that translation keys are strings that resolve<br>to another string, the translated one. The strings are often namespaced to give some context to the translator as well<br>as help managing translations.

For example, in Vue a translation is often done in the template such as $t('login.submit.button') }}, which would<br>resolve to the string "Log in". There is often support to pass in contextual parameters such as<br>$t('login.email.sent', { email: 'jane@example.com' }) }}, which would resolve to "Sent a login link to jane@example.com"<br>if the translation key was something along the lines of:

"en": {<br>"login": {<br>"email": {<br>"sent": "Sent a login link to {email}"

I do not want to complain about JSON2 or JavaScript, I would probably have built my i18n solution along the same lines<br>as the code above if I was using JavaScript.<br>2. I am just grateful that it is not YAML!<br>Nonetheless, there are a few weak points here:

The translation keys are strings, what happens if we pass something that does not exist login.something.unknown?

The string interpolation is fragile, what if we do not pass in a parameter, or pass in the wrong key, or the wrong type?

The translations are just objects, what if the sv (Swedish) object lacks some keys?

You have to use other tools such as automated test, linters, etc. to make sure you pass in the correct parameters. And<br>yet another tool to make sure all languages have translations.

Fortunately enough, I am not writing my application in JavaScript3, I am using Gleam!<br>3. JavaScript is my safeword in case of being kidnapped.<br>Gleam is a very nice statically typed language that gives us the building blocks we need to add translation support<br>that makes it a lot harder to pass in the wrong parameters, and that also fails to compile should some language miss<br>a translation.

Show me the code

As I said, my application is small, and the journey is the goal, so I do not mind adding a homebrew i18n solution<br>instead of using a third-party library.

Here is my version of how to use the i18n with Lustre:

import eliasson/i18n<br>import lustre/element/html

// Use the translation of the phrase "Check your inbox".<br>html.h1([], [i18n.label(i18n.English, i18n.CheckInbox)]

// Some translations, such as "Member" support pluralization.<br>html.p([], [i18n.label(i18n.Swedish, i18n.Member(i18n.Plural))])

The i18n.label function takes a language (which is declared as type variants) and the translation key and returns<br>a string.

Here are the basic parts of my i18n module:

import gleam/list<br>import gleam/string

pub type Language {<br>English<br>Swedish

pub type Cardinality {<br>Singular<br>Plural

pub type Inline {<br>PlainText(String)

pub type I18NKey {<br>/// Common word for member.<br>Member(Cardinality)<br>/// Heading shown after submitting the login form.<br>CheckInbox

/// Translate `key` for `lang` and return a capitalised plain string.<br>/// Intended for labels, headings, and button text where capitalisation is expected.<br>pub fn label(lang: Language, key: I18NKey) -> String {<br>translate(lang, key)<br>|> to_string<br>|> string.capitalise

fn translate(lang: Language, key: I18NKey) -> List(Inline) {<br>case lang, key {<br>English, Member(Singular) -> [PlainText("member")]<br>English, Member(Plural) -> [PlainText("members")]<br>Swedish, Member(Singular) -> [PlainText("medlem")]<br>Swedish, Member(Plural) -> [PlainText("medlemmar")]

English, CheckInbox -> [PlainText("check your inbox")]<br>Swedish, CheckInbox -> [PlainText("kolla din inkorg")]

Let's unpack what is going on.

A translation is basically pattern matching on the combination of language , translation key , and cardinality if the key requires it.

In the example above, all translations return a plain text string.

The label function is a convenience function that capitalises the translation.

What is not visible, and that might not be obvious if you are not familiar with Gleam, is that:

It is impossible to pass a language that is not declared in the Language type.

It is impossible to pass a translation key that is not declared in the I18NKey type.

If the translation key requires a cardinality, you have to specify it...

i18n translation gleam type language string

Related Articles