13

I need to internationalize the UI strings in my ELM HTML application to 3 different languages.

I am thinking of doing this:

1) I will get the currentLanguage from Javascript, and pass it down in the ProgramWithFlags. I'llkeep the language in the model

2) I'll setup some types in my code

type alias Languages = English | French | Spanish
-- One of these for each string I want to internationalize
type alias InternationalizedStrings = StringHello | StringFoo | StringBar

3) I'll make a function for returning each translated phrase to use in my Views.

getPhrase: InternationalizationString Languages -> string
getPhrase stringId lang = 
   case lang of
      English ->
           case stringId of
              StringHello -> "Hello"
              StringFoo -> "Foo"
              StringBar -> "Bar"
      French ->
           case stringId of
              StringHello -> "Bonjour"
              StringFoo -> "Oui"
              StringBar -> "Non"
      ...

Is there a better way to do this? I have lots of string.

jm.
  • 23,422
  • 22
  • 79
  • 93
  • What you could maybe do is serve a certain model type based on the language code in the URL. For example, `/fr/phrase` would serve frenchModel – DevNebulae Oct 27 '16 at 09:18
  • 1
    One advantage to using union types is that each tag can have parameters. You could have a `StringHello String` tag that indicates you need to specify the user name, and it's strongly typed in a way that normal dictionary lookups cannot be. – Chad Gilbert Oct 27 '16 at 12:00

3 Answers3

4

In case you want compiler errors when you don't provide the translation for a string your solution is on the right track.

If you want to either allow yet untranslated strings or find it tedious to have a type for every translatable string, you might want to switch to a Dict-based solution. to tinker with it, just throw it into http://elm-lang.org/try:

import Dict exposing (Dict)
import Html exposing (text)


type Language
    = English
    | French
    | Spanish


type alias Key =
    String


main =
    text <| translate French "Hello"


translate : Language -> Key -> String
translate lang key =
    let
        dict =
            case lang of
                English ->
                    Dict.fromList
                        [ ( "Hello", "in english" )
                        ]

                French ->
                    Dict.fromList
                        [ ( "Hello", "salut" )
                        ]

                Spanish ->
                    Dict.fromList
                        [ ( "Hello", "hola" )
                        , ( "someKeyThatOnlyExistsInSpanish", "42" )
                        ]
    in
        Dict.get key dict |> Maybe.withDefault ("can not find translation for " ++ key)
pierrebeitz
  • 221
  • 1
  • 7
2

A while ago, I had a crack at internationalisation, and came up with the following setup:

  • define the language in a global model
  • have a very simple function, to be used in view modules and functions
  • the function has a signature of localString : Language -> String -> String
  • localString basically does a lookup in a global dictionary to find a translation from the word you provide to the language you provide.
  • it will always give back a String, defaulting to the original word, if it cannot find the word you provide, or if it cannot find the translation to the language you provide.
  • keep the global dictionary (and helper) functions NOT in the model, but in a separate file (it is pretty static data, which won't change in runtime).
  • the Language type is a Union Type, to ensure we only have 'approved' languages.
  • the actual dictionary uses conversions to string. The Dict type does not allow strong types as the key.

That way, using internationalisation has minimal impact on the rest of the code:

  • You need to add a Language to your Model (which you could get through JS port)
  • You can still use short and readable code in your views to translate, like

    p [] [ text <| localString model.language "car" ]

  • All hardcoded strings in your own code remain in one simple default language, to keep the rest of your code readable.

Here is the gist of what I was working on, you can copy/ paste to elm-lang.org/try (not fully tested functionally or performance-wise with large numbers of strings and translations)

import Html exposing (div, p, text)
import Dict exposing (Dict)

-- Manage your languages below

type Language = English | Spanish | French

defaultLanguage : Language
defaultLanguage = English

languageToKey : Language -> LanguageKey
languageToKey language =
  case language of
    English -> "English"
    Spanish -> "Spanish"
    French -> "French"

keyToLanguage : LanguageKey -> Language
keyToLanguage key =
  case key of
    "English" -> English
    "Spanish"-> Spanish
    "French" -> French
    _ -> defaultLanguage

english : LocalWord -> (Language, LocalWord)
english word =
  (English, word)

spanish : LocalWord -> (Language, LocalWord)
spanish word =
  (Spanish, word)

french : LocalWord -> (Language, LocalWord)
french word =
  (French, word)

-- Internal stuff

type alias Word = String
type alias LocalWord = String
type alias LanguageKey = String

type alias Dictionary = Dict Word WordDict
type alias WordDict = Dict LanguageKey LocalWord

init : Dictionary
init =
  Dict.fromList []

newLocalWord : Word -> (Language, LocalWord) -> Maybe WordDict -> Maybe WordDict
newLocalWord word (localLanguage, localWord) wordDict =
  wordDict
  |> Maybe.withDefault (Dict.fromList [])
  |> Dict.insert (languageToKey defaultLanguage) word
  |> Dict.insert (languageToKey localLanguage) localWord
  |> Just

addTranslation : Word -> (Language, LocalWord) -> Dictionary -> Dictionary
addTranslation word newTranslation dictionary =
  dictionary
  |> Dict.update word (newLocalWord word newTranslation)

localString : Language -> Word -> LocalWord
localString language word =
  let
    wordEntry =
      Dict.get word globalDictionary
    localLanguage =
      languageToKey language
  in
    case wordEntry of
      Just wordDict ->
        Dict.get localLanguage wordDict
        |> Maybe.withDefault word

      Nothing ->
        word

add : Word -> List (Language, LocalWord) -> Dictionary -> Dictionary
add word translationList dictionary =
  List.foldl (addTranslation word) dictionary translationList

-- BUILD DICTIONARY BELOW

globalDictionary : Dictionary
globalDictionary =
  init
  |> add "Hello" [ spanish "Hola", french "Bonjour" ]
  |> add "Man" [ spanish "Hombre", french "Homme" ]
  |> add "Child" [ french "Enfant" ]


-- For Elm-lang Try only
localModel = 
  { language = Spanish }

main =
  div []
    [ p [] 
      [ text <| "Hello in Spanish: " 
      ++ localString localModel.language "Hello" 
      ]
    , p [] 
      [ text <| "In dictionary, but not in Spanish: " 
      ++ localString localModel.language "Child" 
      ]
    , p [] 
      [ text <| "Is not in dictionary: "
      ++ localString localModel.language "Car" 
      ]
    ]
wintvelt
  • 13,855
  • 3
  • 38
  • 43
2

I wrote a blog post about this a couple months ago. If you have the ability, try to prefer using ADTs over Dicts since Dicts can't give you the same guarantees at a type level (which is why Dict.get returns Maybe a). ADTs can also have the data type you're acting on type checked as well MyPhrase Int String that you can pattern match on and use whatever toString method you'd like (e.g. MyPhrase foo bar -> "My phrase contains " ++ toString foo ++ " & " ++ bar ++ "."). That being said, existing systems/translation services might make it difficult to use this method without writing a parser from .elm to .json or .po.

toastal
  • 1,056
  • 8
  • 16