Seer Stones

Translations Using Unions in Elm

Abstract

In many languages, to do translations, a team would pull in a library to handle some of the basic internationalization (i18n). In Elm tho, we have access to tools to do this on the language level without a library & instead can use a couple of functions & unions—lessening our dependencies.

So what’s so awesome about using union types? Unlike a Dict (or a JavaScript object), we get type safety for the translations to verify that they exist & we’ve covered all the cases. It’ll be very easy to reason about the translations since everything is just a couple of case statements. It will look a bit fluffy, but that’s elm-format for you.

The accompanying source code for this post is in this Git repository. There’s also a demo—so that’s kinda neat.


So What Are the Pieces to the Translation Puzzle?

Langauage

Our Language is a union of the languages the application supports.

type Language
    = EnUk
    | EnUs
    | EsMx

Phrase

These are the basic building blocks & represent the phrases that the application will be translating.

type Phrase
    = Greeting String
    | Name
    | TextColor String
    | Color
    | Red
    | Blue
    | Green

Translator

An alias for function from our phrase to a string to display.

type alias Translator =
    Phrase -> String

translate

Part 1: A top-level function that can be partially applied with our language at the view level that selects the appropriate translate function for the rest of the application.

translate : Language -> Translator
translate lang =
    case lang of
        EnUk ->
            EnUk.translate
        EnUs ->
            EnUs.translate
        EsMx ->
            EsMx.translate

Part 2: Breaking down the top-level function into sub-level translations

This will make this easier to digest along with giving us a hand-off-able file for translators to do their thing.

translate : Phrase -> String
translate phrase =
    case phrase of
        Greeting name ->
            "Yo, " ++ name ++ "!"
        Name ->
            "Name"
        TextColor color ->
            "The color of this text is " ++ color ++ "."
        Color ->
            "Color"
        Red ->
            "red"
        Blue ->
            "blue"
        Green ->
            "green"

So between these 4.5 concepts we can wield an entire way to translate across our application. In this example an I18n folder with a structure looks like this:

├── Languages
│   ├── EnUk.elm
│   ├── EnUs.elm
│   └── EsMx.elm
├── I18n.elm
└── Phrases.elm

…where Phrases.elm contains our Phrase union, Languages/*.elm contains just the translate function for its corresponding language, & I18n.elm contains the rest of the Translator, Language union, & the top-level translate.

Hold Up: There’s Another Thing We’ll Need Though

And that is a way to get from a string sent to our app to the Language union. I’ll be using this guy since it fits my needs of peeling out navigator.language from the browser (i.e. a string like en-US or es_MX). We would traverse the array from navigator.languages if support was better:

import Regex
import String


toLanguage : String -> Language
toLanguage lang =
    let
        -- will split our string on non-chars, take the first
        -- 2 matches, & lowercase them
        codeFinder : String -> List String
        codeFinder =
            Regex.split (Regex.AtMost 2) (Regex.regex "[^A-Za-z]")
                >> List.take 2
                >> List.map String.toLower

        -- pattern that regex into Tuple2 of Maybes
        -- containing the ( language code, country code )
        locale : ( Maybe String, Maybe String )
        locale =
            case codeFinder lang of
                a :: b :: _ ->
                    ( Just a, Just b )
                a :: _ ->
                    ( Just a, Nothing )
                _ ->
                    ( Nothing, Nothing )
    in
        -- Using pattern matching & wildcards, we can
        -- choose the appropriate language & fallbacks
        case locale of
            ( Just "en", Just "uk" ) ->
                EnUk
            ( Just "en", Just "au" ) ->
                EnUk
            ( Just "en", Just "nz" ) ->
                EnUk
            ( Just "en", _ ) ->
                EnUs
            ( Just "es", _ ) ->
                EsMx
            _ ->
                EnUs

So What’s the Most Basic Example of Putting This All Together?

import Html exposing (text)
import I18n.I18n as I18n exposing (Translator)
import I18n.Phrases as Phrases

main : Html String
main =
    let
        -- in an app, we’d build a `translate` function
        -- here & hand it around to our views so they
        -- can reference it
        translate : Translator
        translate =
            I18n.translate (18n.toLanguage "en-US")
    in
        -- in composing `text` & `translate` we have
        -- `Phrase -> Html String`
        text (translate (Phrases.Greeting "toastal"))

-- displays: Yo, toastal!

But Let’s Get “Fancy” With an Example

(Those are air quotes)

See the source here


Loading…

Takeaway

Using a couple language-level features in Elm—unions, cases, & functions—we can create a seer stone for our app to translate all of our custom phrases. It’s hardly complicated enough to require a library for a simple use case. One thing to keep in mind tho is depending on your translation service, you may have to create some parser/Elm code generator to move from another format that’s not a .elm file if required, but that shouldn’t be terribly complicated.