<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>JaggerJo&#39;s Blog</title>
    <link>https://jaggerjo.writeas.com/</link>
    <description>mostly about programming</description>
    <pubDate>Tue, 21 Apr 2026 10:37:01 +0000</pubDate>
    <item>
      <title>Painless translations with F# and a bit of Magic.</title>
      <link>https://jaggerjo.writeas.com/painless-translations-with-f-and-a-bit-of-magic?pk_campaign=rss-feed</link>
      <description>&lt;![CDATA[A few weeks ago the unavoidable happened, a customer asked for a translated (non English) version of our SPA. We knew this would happen at some point, but as it happens always had more important work in our backlog.&#xA;&#xA;There was no way to postpone this further.&#xA;&#xA;Our requirements:&#xA;&#xA;Translations can be outsourced/ done by non developers&#xA;Translations can be changed without releasing a new version&#xA;&#xA;From a developer perspective translated apps usually are a bit of a pain. Whenever you add a string somewhere in the codebase you usually need to go somewhere else and create a translation unit (key, invariant) you can then reference in the code again.&#xA;&#xA;What if we could make it possible to add translations without all that trouble?&#xA;&#xA;IMHO we did a good job at making this as painless as possible. Below are the details on how this is used and how it’s implemented. &#xA;&#xA;Translations&#xA;&#xA;Translations are stored in a git repo as simple JSON files with plural support.&#xA;&#xA;Example:&#xA;&#xA;{&#xA;  &#34;common&#34; : {&#xA;      &#34;name&#34; : &#34;Name&#34;&#xA;      &#34;label&#34; : &#34;Label&#34;&#xA;      &#34;labelplural&#34; : &#34;Labels&#34;&#xA;  }&#xA;}&#xA;&#xA;Exposing JSON files to translators is not really an option. Therefore all translaitons are managed via weblate and changes are synced back to our git repository.&#xA;&#xA;Discovering new Translation Keys&#xA;&#xA;Usually adding new translations is a pain, as you need to register a key and invariant string somewhere. To make this less painful we took a different approach, automagically extracting translation keys and invariant strings from the codebase. This is done by scanning the compiled assembiles for calls that look like the example below.&#xA;&#xA;tr(&#34;common.label&#34;, &#34;Label&#34;, &#34;Labels&#34;)&#xA;tr(&#34;common.name&#34;, &#34;Name&#34;)&#xA;&#xA;Well, actually we look for IL instructions, not F\# code.&#xA;&#xA;IL0000: ldstr        &#34;common.label&#34;&#xA;IL0005: ldstr        &#34;Label&#34;&#xA;IL000a: ldstr        &#34;Labels&#34;&#xA;IL000f: newobj       instance void Codename.Shared.I18nString::.ctor(string, string, string)&#xA;&#xA;IL0015: ldstr        &#34;common.name&#34;&#xA;IL001a: ldstr        &#34;Name&#34;&#xA;IL001f: newobj       instance void Codename.Shared.I18nString::.ctor(string, string)&#xA;&#xA;All matching calls are accumulated and compared to the keys we already registered. We also automatically do things like:&#xA;&#xA;Add new translation keys and their invariant (singular + plural) strings to weblate&#xA;Mark translations as &#34;need editing&#34; (in weblate) if the invariant string changes for a key.&#xA;Remove unused translations in weblate (and git) - if activated.&#xA;Detect duplicate keys and notify the developer&#xA;Detect invalid translation calls&#xA;&#xA;Translating a &#39;Thing&#39; in the codebase&#xA;&#xA;Translating something works the same across Codename.Server, Codename.Shared &amp; Codename.Client.&#xA;&#xA;( Client )&#xA;UI.Btn (&#xA;    style = ButtonStyle.Primary,&#xA;    label =  tr(&#34;common.save&#34;, &#34;Save&#34;).Localized(),&#xA;    onClick = ignore&#xA;)&#xA;                        &#xA;( Shared )&#xA;type Priority =&#xA;    | Low&#xA;    | Medium&#xA;    | High&#xA;    | Critical with&#xA;&#xA;    member this.FriendlyStringValue =&#xA;        match this with&#xA;        | Low -  tr(&#34;Priority.Low&#34;, &#34;Low&#34;).Localized()&#xA;        | Medium -  tr(&#34;Priority.Medium&#34;, &#34;Medium&#34;).Localized()&#xA;        | High -  tr(&#34;Priority.High&#34;, &#34;High&#34;).Localized()&#xA;        | Critical -  tr(&#34;Priority.Critical&#34;, &#34;Critical&#34;).Localized()&#xA;&#xA;( Server - Client Remoting API response )&#xA;GreetingTest = requireLoggedIn (fun app  -  async {&#xA;        return  tr(&#34;common.greeting&#34;, &#34;Hello from the server!&#34;).Localized()&#xA;    }&#xA;)&#xA;&#xA;Translations with placeholders&#xA;&#xA;It is possible to use .NET format strings and provide replacement in the call to Localized. There is no restriction on the argument count.&#xA;&#xA; tr(&#34;common.assignedToUser&#34;, &#34;Assigned to {0}&#34;).Localized(&#34;Peter&#34;)&#xA; tr(&#34;common.fromTo&#34;, &#34;from {0} to {1}&#34;).Localized(&#34;42&#34;, &#34;64&#34;)&#xA;&#xA;Singular and Plural translations&#xA;&#xA;Translations get complicated fast, to make this all simpler the translation system supports plurals.&#xA;&#xA;tr(&#34;common.label&#34;, &#34;Label&#34;, &#34;Labels&#34;).Localized() // -  &#34;Label&#34;&#xA;tr(&#34;common.label&#34;, &#34;Label&#34;, &#34;Labels&#34;).Localized(usePlural = true) // -  &#34;Labels&#34;&#xA;&#xA;Custom Operators for Translations&#xA;&#xA;To make life a bit easier there are custom operators calling .Localized() and .Localized(usePlural = true).&#xA;&#xA;let (!@) (a: I18nString) : string =&#xA;    a.Localized()&#xA;&#xA;let (!@@) (a: I18nString) : string =&#xA;    a.Localized(usePlural = true)&#xA;&#xA;Now instead of calling Localized we can do the following:&#xA;&#xA;!@ tr(&#34;common.label&#34;, &#34;Label&#34;, &#34;Labels&#34;) // -  &#34;Label&#34;&#xA;!@@ tr(&#34;common.label&#34;, &#34;Label&#34;, &#34;Labels&#34;) // -  &#34;Labels&#34;&#xA;&#xA;Things to be aware of&#xA;&#xA;Translation calls need to be simple so we can detect them and find their argument values.&#xA;&#xA;module TK =&#xA;    let common = &#34;common.&#34;&#xA;&#xA;    // 💥 invalid&#xA;    let label = tr(common + &#34;label&#34;, &#34;Label&#34;, &#34;Labels&#34;)&#xA;    &#xA;    // 💥 invalid&#xA;    let label = tr(sprintf &#34;%slabel&#34; common, &#34;Label&#34;, &#34;Labels&#34;)&#xA;&#xA;    // 💥 invalid&#xA;    let label = tr($&#34;{common}label&#34;, &#34;Label&#34;, &#34;Labels&#34;)&#xA;    &#xA;    // ✅ valid&#xA;    let label = tr(&#34;common.label&#34;, &#34;Label&#34;, &#34;Labels&#34;)&#xA;&#xA;As translations should be resolved lazily (so they are not detached from the users preference).&#xA;&#xA;module SomeModule =&#xA;&#xA;    // 💥 don&#39;t do this. Translations will always show the invariant string. &#xA;    // if you want to use a translation in multiple places put the I18nString&#xA;    // in a shared module.&#xA;    let label = !@ tr(&#34;common.label&#34;, &#34;Label&#34;, &#34;Labels&#34;)&#xA;    &#xA;    // ✅ share translations in their untranslated state. &#xA;    module TK =&#xA;        let label = tr(&#34;common.label&#34;, &#34;Label&#34;, &#34;Labels&#34;)&#xA;&#xA;    // 💥 don&#39;t do this. Translations will always show the invariant string. &#xA;    let headers = [&#xA;        UI.Table.TableHeaderCell (TK.Actions.actionNumber.Localized())&#xA;        UI.Table.TableHeaderCell (TK.Actions.actionTitle.Localized())&#xA;        UI.Table.TableHeaderCell (TK.Common.priority.Localized())&#xA;    ]&#xA;&#xA;    // 💥 don&#39;t do this. Translations are only resolved once.&#xA;    // - Client: changing the language will not update this value&#xA;    // - Server: requests with different langauge contexts get wrong values&#xA;    let headers = lazy [&#xA;        UI.Table.TableHeaderCell (TK.Actions.actionNumber.Localized())&#xA;        UI.Table.TableHeaderCell (TK.Actions.actionTitle.Localized())&#xA;        UI.Table.TableHeaderCell (TK.Common.priority.Localized())&#xA;    ]&#xA;&#xA;    // ✅ translations are always resolved when needed&#xA;    let headers () = [&#xA;        UI.Table.TableHeaderCell (TK.Actions.actionNumber.Localized())&#xA;        UI.Table.TableHeaderCell (TK.Actions.actionTitle.Localized())&#xA;        UI.Table.TableHeaderCell (TK.Common.priority.Localized())&#xA;    ]&#xA;&#xA;Implementation &#34;Details&#34;&#xA;&#xA;Determining the current active language&#xA;&#xA;No matter if we want to lookup a translation on the server (.NET), client (Fable) or in shared (.NET/Fable) we need to know which language to use.&#xA;&#xA;Client - Fable&#xA;&#xA;When a user logs into the app we fetch the preferred language from the backend and store it locally. We now include that language in all http requests to the server.&#xA;&#xA;Server - .NET&#xA;&#xA;On the server we use request localization. This works by extracting the language header the client provided and setting the language for the request context.&#xA;&#xA;if FABLECOMPILER&#xA;[RequireQualifiedAccess]&#xA;module ClientLanguage =&#xA;    let mutable currentUserLanguage = Language.Invariant&#xA;endif&#xA;&#xA;type Language with&#xA;    static member Current: Language =&#xA;        #if !FABLE_COMPILER&#xA;&#xA;        ( Get current language from current culture info on the server.&#xA;           Explaining why this works even tho tasks are not tied to a specific thread is a bit&#xA;           complicated - but here is a very good explanation (It uses AsyncLocalT).&#xA;&#xA;           read me -  https://github.com/dotnet/runtime/issues/48077&#xA;         )&#xA;&#xA;        CultureInfo.CurrentCulture.TwoLetterISOLanguageName&#xA;        |  Language.OfString&#xA;        |  Option.defaultValue Language.Invariant&#xA;&#xA;        #else&#xA;        ( Manually set by the client app to match the users preference )&#xA;&#xA;        ClientLanguage.currentUserLanguage&#xA;        #endif&#xA;&#xA;Getting a Translation table&#xA;&#xA;I18nString.Localize() uses TranslationProvider.Shared.Lookup internally to get a localized value. The TranslationProvider has the following public api.&#xA;&#xA;type TranslationProvider =   &#xA;    member Lookup: language: Language * key: string -  TranslationLookupUnit option   &#xA;    member TranslationTable: language: Language -  TranslationLookupTable   &#xA;    &#xA;    static member Init: provider: ITranslationStore -  unit   &#xA;    static member Shared: TranslationProvide&#xA;&#xA;The Init method needs to be called with a ITranslationStore before we can resolve any translation.&#xA;&#xA;Depending on the context (Server/Client) a different implementation for the ITranslationStore interface needs to be provided.&#xA;&#xA;type ITranslationStore =&#xA;    abstract GetTranslationTable: Language -  TranslationLookupTable&#xA;`]]&gt;</description>
      <content:encoded><![CDATA[<p>A few weeks ago the unavoidable happened, a customer asked for a translated (non English) version of our SPA. We knew this would happen at some point, but as it happens always had more important work in our backlog.</p>

<p>There was no way to postpone this further.</p>

<p>Our requirements:</p>
<ul><li>Translations can be outsourced/ done by non developers</li>
<li>Translations can be changed without releasing a new version</li></ul>

<p>From a developer perspective translated apps usually are a bit of a pain. Whenever you add a string somewhere in the codebase you usually need to go somewhere else and create a translation unit (<code>key</code>, <code>invariant</code>) you can then reference in the code again.</p>

<p>What if we could make it possible to add translations without all that trouble?</p>

<p>IMHO we did a good job at making this as painless as possible. Below are the details on how this is used and how it’s implemented.</p>

<h1 id="translations" id="translations">Translations</h1>

<p>Translations are stored in a git repo as simple JSON files with plural support.</p>

<p>Example:</p>

<pre><code class="language-json">{
  &#34;common&#34; : {
      &#34;name&#34; : &#34;Name&#34;
      &#34;label&#34; : &#34;Label&#34;
      &#34;label_plural&#34; : &#34;Labels&#34;
  }
}
</code></pre>

<p>Exposing JSON files to translators is not really an option. Therefore all translaitons are managed via <a href="https://weblate.org" title="weblate" rel="nofollow">weblate</a> and changes are synced back to our git repository.</p>

<h1 id="discovering-new-translation-keys" id="discovering-new-translation-keys">Discovering new Translation Keys</h1>

<p>Usually adding new translations is a pain, as you need to register a key and invariant string somewhere. To make this less painful we took a different approach, automagically extracting translation keys and invariant strings from the codebase. This is done by scanning the compiled assembiles for calls that look like the example below.</p>

<pre><code class="language-fsharp">tr(&#34;common.label&#34;, &#34;Label&#34;, &#34;Labels&#34;)
tr(&#34;common.name&#34;, &#34;Name&#34;)
</code></pre>

<p>Well, actually we look for IL instructions, not F# code.</p>

<pre><code class="language-asm">IL_0000: ldstr        &#34;common.label&#34;
IL_0005: ldstr        &#34;Label&#34;
IL_000a: ldstr        &#34;Labels&#34;
IL_000f: newobj       instance void Codename.Shared.I18nString::.ctor(string, string, string)

IL_0015: ldstr        &#34;common.name&#34;
IL_001a: ldstr        &#34;Name&#34;
IL_001f: newobj       instance void Codename.Shared.I18nString::.ctor(string, string)
</code></pre>

<p>All matching calls are accumulated and compared to the keys we already registered. We also automatically do things like:</p>
<ul><li>Add new translation keys and their invariant (singular + plural) strings to weblate</li>
<li>Mark translations as “need editing” (in weblate) if the invariant string changes for a key.</li>
<li>Remove unused translations in weblate (and git) – if activated.</li>
<li>Detect duplicate keys and notify the developer</li>
<li>Detect invalid translation calls</li></ul>

<h1 id="translating-a-thing-in-the-codebase" id="translating-a-thing-in-the-codebase">Translating a &#39;Thing&#39; in the codebase</h1>

<p>Translating something works the same across <code>Codename.Server</code>, <code>Codename.Shared</code> &amp; <code>Codename.Client</code>.</p>

<pre><code class="language-fsharp">(* Client *)
UI.Btn (
    style = ButtonStyle.Primary,
    label =  tr(&#34;common.save&#34;, &#34;Save&#34;).Localized(),
    onClick = ignore
)
                        
(* Shared *)
type Priority =
    | Low
    | Medium
    | High
    | Critical with

    member this.FriendlyStringValue =
        match this with
        | Low -&gt; tr(&#34;Priority.Low&#34;, &#34;Low&#34;).Localized()
        | Medium -&gt; tr(&#34;Priority.Medium&#34;, &#34;Medium&#34;).Localized()
        | High -&gt; tr(&#34;Priority.High&#34;, &#34;High&#34;).Localized()
        | Critical -&gt; tr(&#34;Priority.Critical&#34;, &#34;Critical&#34;).Localized()

(* Server - Client Remoting API response *)
GreetingTest = requireLoggedIn (fun app _ -&gt;
    async {
        return  tr(&#34;common.greeting&#34;, &#34;Hello from the server!&#34;).Localized()
    }
)
</code></pre>

<h2 id="translations-with-placeholders" id="translations-with-placeholders">Translations with placeholders</h2>

<p>It is possible to use .NET format strings and provide replacement in the call to <code>Localized</code>. There is no restriction on the argument count.</p>

<pre><code class="language-fsharp"> tr(&#34;common.assignedToUser&#34;, &#34;Assigned to {0}&#34;).Localized(&#34;Peter&#34;)
 tr(&#34;common.fromTo&#34;, &#34;from {0} to {1}&#34;).Localized(&#34;42&#34;, &#34;64&#34;)
</code></pre>

<h2 id="singular-and-plural-translations" id="singular-and-plural-translations">Singular and Plural translations</h2>

<p>Translations get complicated fast, to make this all simpler the translation system supports plurals.</p>

<pre><code class="language-fsharp">tr(&#34;common.label&#34;, &#34;Label&#34;, &#34;Labels&#34;).Localized() // -&gt; &#34;Label&#34;
tr(&#34;common.label&#34;, &#34;Label&#34;, &#34;Labels&#34;).Localized(usePlural = true) // -&gt; &#34;Labels&#34;
</code></pre>

<h2 id="custom-operators-for-translations" id="custom-operators-for-translations">Custom Operators for Translations</h2>

<p>To make life a bit easier there are custom operators calling <code>.Localized()</code> and <code>.Localized(usePlural = true)</code>.</p>

<pre><code class="language-fsharp">let (!@) (a: I18nString) : string =
    a.Localized()

let (!@@) (a: I18nString) : string =
    a.Localized(usePlural = true)
</code></pre>

<p>Now instead of calling <code>Localized</code> we can do the following:</p>

<pre><code class="language-fsharp">!@ tr(&#34;common.label&#34;, &#34;Label&#34;, &#34;Labels&#34;) // -&gt; &#34;Label&#34;
!@@ tr(&#34;common.label&#34;, &#34;Label&#34;, &#34;Labels&#34;) // -&gt; &#34;Labels&#34;
</code></pre>

<h2 id="things-to-be-aware-of" id="things-to-be-aware-of">Things to be aware of</h2>

<p>Translation calls need to be simple so we can detect them and find their argument values.</p>

<pre><code class="language-fsharp">module TK =
    let common = &#34;common.&#34;

    // 💥 invalid
    let label = tr(common + &#34;label&#34;, &#34;Label&#34;, &#34;Labels&#34;)
    
    // 💥 invalid
    let label = tr(sprintf &#34;%slabel&#34; common, &#34;Label&#34;, &#34;Labels&#34;)

    // 💥 invalid
    let label = tr($&#34;{common}label&#34;, &#34;Label&#34;, &#34;Labels&#34;)
    
    // ✅ valid
    let label = tr(&#34;common.label&#34;, &#34;Label&#34;, &#34;Labels&#34;)
</code></pre>

<p>As translations should be resolved lazily (so they are not detached from the users preference).</p>

<pre><code class="language-fsharp">module SomeModule =

    // 💥 don&#39;t do this. Translations will always show the invariant string. 
    // if you want to use a translation in multiple places put the `I18nString`
    // in a shared module.
    let label = !@ tr(&#34;common.label&#34;, &#34;Label&#34;, &#34;Labels&#34;)
    
    // ✅ share translations in their untranslated state. 
    module TK =
        let label = tr(&#34;common.label&#34;, &#34;Label&#34;, &#34;Labels&#34;)

    // 💥 don&#39;t do this. Translations will always show the invariant string. 
    let headers = [
        UI.Table.TableHeaderCell (TK.Actions.actionNumber.Localized())
        UI.Table.TableHeaderCell (TK.Actions.actionTitle.Localized())
        UI.Table.TableHeaderCell (TK.Common.priority.Localized())
    ]

    // 💥 don&#39;t do this. Translations are only resolved once.
    // - Client: changing the language will not update this value
    // - Server: requests with different langauge contexts get wrong values
    let headers = lazy [
        UI.Table.TableHeaderCell (TK.Actions.actionNumber.Localized())
        UI.Table.TableHeaderCell (TK.Actions.actionTitle.Localized())
        UI.Table.TableHeaderCell (TK.Common.priority.Localized())
    ]

    // ✅ translations are always resolved when needed
    let headers () = [
        UI.Table.TableHeaderCell (TK.Actions.actionNumber.Localized())
        UI.Table.TableHeaderCell (TK.Actions.actionTitle.Localized())
        UI.Table.TableHeaderCell (TK.Common.priority.Localized())
    ]
</code></pre>

<h1 id="implementation-details" id="implementation-details">Implementation “Details”</h1>

<h2 id="determining-the-current-active-language" id="determining-the-current-active-language">Determining the current active language</h2>

<p>No matter if we want to lookup a translation on the server (<code>.NET</code>), client (<code>Fable</code>) or in shared (<code>.NET</code>/<code>Fable</code>) we need to know which language to use.</p>

<h3 id="client-fable" id="client-fable">Client – Fable</h3>

<p>When a user logs into the app we fetch the preferred language from the backend and store it locally. We now include that language in all http requests to the server.</p>

<h3 id="server-net" id="server-net">Server – .NET</h3>

<p>On the server we use request localization. This works by extracting the language header the client provided and setting the language for the request context.</p>

<pre><code class="language-fsharp">#if FABLE_COMPILER
[&lt;RequireQualifiedAccess&gt;]
module ClientLanguage =
    let mutable currentUserLanguage = Language.Invariant
#endif

type Language with
    static member Current: Language =
        #if !FABLE_COMPILER

        (* Get current language from current culture info on the server.
           Explaining why this works even tho tasks are not tied to a specific thread is a bit
           complicated - but here is a very good explanation (It uses AsyncLocal&lt;T&gt;).

           read me -&gt; https://github.com/dotnet/runtime/issues/48077
         *)

        CultureInfo.CurrentCulture.TwoLetterISOLanguageName
        |&gt; Language.OfString
        |&gt; Option.defaultValue Language.Invariant

        #else
        (* Manually set by the client app to match the users preference *)

        ClientLanguage.currentUserLanguage
        #endif
</code></pre>

<h2 id="getting-a-translation-table" id="getting-a-translation-table">Getting a Translation table</h2>

<p><code>I18nString.Localize()</code> uses <code>TranslationProvider.Shared.Lookup</code> internally to get a localized value. The <code>TranslationProvider</code> has the following public api.</p>

<pre><code class="language-fsharp">type TranslationProvider =   
    member Lookup: language: Language * key: string -&gt; TranslationLookupUnit option   
    member TranslationTable: language: Language -&gt; TranslationLookupTable   
    
    static member Init: provider: ITranslationStore -&gt; unit   
    static member Shared: TranslationProvide
</code></pre>

<p>The <code>Init</code> method needs to be called with a <code>ITranslationStore</code> before we can resolve any translation.</p>

<p>Depending on the context (Server/Client) a different implementation for the <code>ITranslationStore</code> interface needs to be provided.</p>

<pre><code class="language-fsharp">type ITranslationStore =
    abstract GetTranslationTable: Language -&gt; TranslationLookupTable
</code></pre>
]]></content:encoded>
      <guid>https://jaggerjo.writeas.com/painless-translations-with-f-and-a-bit-of-magic</guid>
      <pubDate>Sun, 06 Feb 2022 21:52:44 +0000</pubDate>
    </item>
  </channel>
</rss>