githubEdit

globeTranslations

Overview

Shopfoo uses a custom, type-safe localization system. Translations are organized by page, loaded lazily alongside API responses, and cached in the application's global state. The system avoids a separate round-trip for translations by piggybacking them on existing data queries.

The key files are:

  • Shopfoo.Domain.Types/Translations.fs β€” Core types: PageCode, TagCode, TranslationKey, and the Translations data structure (including the current Lang).

  • Shopfoo.Shared/Translations.fs β€” The AppTranslations facade and page-specific translator classes (Home, Login, Product).

  • Shopfoo.Shared/Remoting.fs β€” FullContext with FillTranslations, QueryDataAndTranslations, and the QueryWithTranslations type alias.

  • Shopfoo.Client/View.fs β€” The Elmish loop that owns the translation cache and handles the dispatched FillTranslations messages. Also contains AppView and the ReactDOM.createRoot entry point.

  • Shopfoo.Client/Pages/Shared.fs β€” The Env.IFillTranslations interface that pages use to push translations into the cache.

Core types

PageCode, TagCode, and TranslationKey

Every translation entry is identified by a composite key: which page it belongs to and a tag within that page.

[<RequireQualifiedAccess>]
type PageCode =
    | Home
    | Login
    | Product

type TagCode = TagCode of code: string

type TranslationKey = { Page: PageCode; Tag: TagCode }

PageCode partitions the translations into page-scoped namespaces. TagCode is a single-case union wrapping a string key β€” for example "Login", "Save", "Theme.Dark", or "PriceAction.Define". Together they form a TranslationKey that uniquely identifies a translated string.

The Translations record

The raw translation data combines the language with a nested map:

  • Lang β€” The language of the translations contained in Pages.

  • Outer map: PageCode β†’ inner map (one entry per page).

  • Inner map: TagCode β†’ translated string.

Two lookup members provide access:

When a key is missing and no default is provided, Get returns a visible fallback string like [*** Product.Price ***], making missing translations immediately obvious during development.

Page translators

The Base class and page-specific classes

The TranslationPages module defines a Base class and three sealed subclasses β€” Home, Login, Product β€” one per PageCode. Each class wraps the raw map lookup behind named, strongly-typed properties:

This gives call sites a discoverable, compile-checked API β€” translations.Home.Save instead of a raw string lookup like translations.Get({ Page = Home; Tag = TagCode "Save" }). IntelliSense lists all available keys for each page, and a typo in a property name is a compile error rather than a silent missing translation at runtime.

WithPrefix for nested keys

Some pages group related keys under a dot-separated prefix. The WithPrefix method on Base creates a scoped sub-translator that automatically prepends the prefix to every lookup:

The result is an anonymous record, so call sites read naturally: translations.Product.PriceAction.MarkAsSoldOutDialog.Confirm.

The access path through AppTranslations and the actual TagCode stored in the data are technically decoupled β€” nothing in the compiler enforces that they match. However, it is strongly recommended to keep them aligned to avoid confusion. In the example above, the correspondence is:

  • Access path: translations.Product .PriceAction .MarkAsSoldOutDialog .Confirm

  • WithPrefix composition: "PriceAction." + "MarkAsSoldOutDialog." + "Confirm"

  • Stored key: TagCode "PriceAction.MarkAsSoldOutDialog.Confirm"

Notice the trick in the definition of member this.PriceAction: the lambda parameter is also named this, shadowing the outer this from the class member. Both are of the same type (Base), but the inner this is a new instance whose buildTagCode function prepends the prefix. This shadowing is intentional β€” it lets the body of the lambda read exactly like a normal class member (this.Get "Define"), while the tag resolution silently includes the accumulated prefix.

Here is the implementation of WithPrefix:

The prefix is captured in a new buildTagCode lambda that composes with the existing one: it prepends the prefix string before delegating to the parent's buildTagCode. When nesting is two levels deep (e.g. PriceAction.MarkAsSoldOutDialog.Confirm), each WithPrefix call wraps the previous buildTagCode, accumulating prefixes through function composition rather than string concatenation at each lookup.

Format for parameterized messages

Some translations are parameterized. They rely on the Format method of Base, which wraps String.Format:

The underlying format strings use {0}, {1} placeholders. For instance, AuthorSearchLimit resolves to "{1} authors found but only {0} are displayed" in English. The benefit is that the parameter count and types are explicit at compile time β€” a caller cannot forget an argument or pass the wrong type β€” even though the format string itself is only resolved at runtime.

Translation data

The actual translation values are stored in Shopfoo.Home/Data/Translations.fs as a simple F# list of tuples (TagCode, english, french), grouped by PageCode:

A repository map is then built by projecting the appropriate column for each language:

This design keeps all translations in a single file, side by side, making it easy to spot missing translations or inconsistencies between languages. The dot-separated keys (e.g. "PriceAction.MarkAsSoldOutDialog.Confirm") are stored flat in the map β€” the hierarchical nesting is reconstructed on the client side by the WithPrefix mechanism described above.

circle-info

In a production application, translations would typically be stored in an external database or a dedicated localization service rather than in a source file. Here, the in-code approach keeps the demo self-contained and avoids an external dependency.

AppTranslations β€” the facade

AppTranslations aggregates the three page translators and tracks which pages have been populated:

Key characteristics:

  • Immutable β€” Fill returns a new AppTranslations instance; the original is unchanged.

  • Lazy recreation β€” Only page translators whose PageCode appears in the incoming Translations map are recreated. Pages that already have data and receive no new data keep their existing translator instance.

  • EmptyPages / PopulatedPages β€” Two computed sets that partition the PageCode values based on whether the page translator has any data. These sets drive the piggyback loading mechanism (see below).

Translation cache in FullContext

The application's global state is held in FullContext, which stores the current AppTranslations:

The current language is a computed property derived from the translations:

Since Translations carries its own Lang, there is no separate Lang field in FullContext β€” the language always stays in sync with the loaded translations.

Two members manage the cache:

FillTranslations merges incoming translations into the existing cache. ResetTranslations clears everything (used on logout).

Loading translations: the piggyback pattern

Translations are not fetched through a dedicated API call at startup. Instead, they are piggybacked onto the first data query each page makes.

QueryWithTranslations

The shared contract defines a query variant that carries translation metadata alongside the domain payload:

The client sends the set of PageCodes it still lacks, and the server returns the corresponding translations alongside the response data:

Index is a QueryWithTranslations β€” translations are piggybacked on the initial data load. GetTranslations is a plain Query used when the user switches the display language from the UI: the client already has data, but needs all populated pages re-translated in the new language (see Language switching below).

PrepareQueryWithTranslations

On the client, FullContext.PrepareQueryWithTranslations automatically includes the empty pages:

This is typically called once per page, in init:

Subsequent calls from the same page use PrepareRequest (no translations needed β€” they are already cached).

Server side

The server receives the TranslationPages set and returns only the missing pages. The handler delegates to the domain API, which filters translations by the user's authorized pages and the requested language:

ResponseBuilder.withTranslations tacks the Translations bundle onto the success value. The client then unpacks the tuple and fills the cache.

How pages update the cache

Pages do not own the translation cache β€” it lives in the root AppView Elmish loop. Pages communicate upward through the Env.IFillTranslations interface:

When a page receives data from the server, its update function pushes the translations into the cache:

For more details on the Env pattern and how child pages communicate with the root AppView, see Data flow β€” The Env pattern.

circle-info

Even error responses can carry translations β€” ApiError has a Translations field. This ensures that error messages can be displayed in the user's language even if the main data load fails.

In View.fs, the root Elmish update function handles the FillTranslations message by merging the incoming data into the global cache:

Language switching

When the user changes the display language, the root AppView fetches translations for all pages that were previously populated β€” there is no need to re-fetch pages that were never loaded:

The Msg, Model, Cmd, init, and update are internal (not private) to allow unit testing from Shopfoo.Client.Tests via InternalsVisibleTo.

Notice that on success, translations are built from scratch (AppTranslations().Fill(...)) rather than merged into the existing cache. This ensures a clean replacement β€” no stale strings from the previous language leak through. Since the Lang is carried by Translations itself, the FullContext.Lang computed property automatically reflects the new language.

Consuming translations in views

Accessing translations

Views access translations through the Env interface:

TranslationsMissing active pattern

For views that must wait for a specific page's translations before rendering, the TranslationsMissing partial active pattern acts as a guard:

Usage in a view:

This pattern treats missing translations the same as loading data β€” both show a skeleton. Once both the data and translations are available, the view renders the full content.

Data flow summary

The full lifecycle of translations follows this path:

  1. Page init β€” Calls PrepareQueryWithTranslations, which includes the set of PageCodes with empty translations (EmptyPages).

  2. Client Cmd β€” The remoting call carries the TranslationPages set to the server.

  3. Server handler β€” Returns the requested translations alongside the response data, filtered by the user's authorized pages and the requested language.

  4. Page update β€” Receives (data, translations) and dispatches env.FillTranslations translations.

  5. Root AppView update β€” Handles Msg.FillTranslations by calling FullContext.FillTranslations, which merges translations into the cache via AppTranslations.Fill.

  6. Views β€” Access translations through AppTranslations.Home, .Login, .Product properties. The TranslationsMissing pattern guards against rendering before translations are available.

  7. Language change β€” Fetches translations for PopulatedPages only, replaces the entire cache with the new language's data.

Recipe: adding a new translation

Case 1 β€” Adding a key to an existing PageCode

Two files to edit:

  1. Shopfoo.Home/Data/Translations.fs β€” Add a new tuple in the appropriate PageCode group:

    For a parameterized message, use {0}, {1}... placeholders:

  2. Shopfoo.Shared/Translations.fs β€” Add a member to the corresponding page translator class (e.g. Home, Login, or Product):

    For a parameterized message:

    For a nested key, add it inside a WithPrefix block, or create a new one.

The view can then use translations.Product.MyNewKey (or translations.Product.MyNewKeyWithParam(42)). No other wiring is needed β€” the key is automatically included in the Translations map for the page and flows through the existing piggyback and cache mechanisms.

Case 2 β€” Adding a new PageCode

This is a more involved change that touches several files across the stack:

  1. Shopfoo.Domain.Types/Translations.fs β€” Add a new case to the PageCode union:

  2. Shopfoo.Home/Data/Translations.fs β€” Add a new PageCode.NewPage group with its translation tuples.

  3. Shopfoo.Shared/Translations.fs β€” Create a new translator class inheriting from Base, then integrate it into AppTranslations:

  4. Shopfoo.Client/Pages/Shared.fs β€” No change needed β€” the Env.IFillTranslations interface is generic over Translations, not PageCode.

  5. Server {Area}ApiBuilder β€” Each API builder defines a static pages set that lists the PageCodes its handlers are allowed to return. Add the new PageCode to the relevant builder(s):

    This pages set is passed as authorizedPageCodes to each SecureQueryDataAndTranslationsHandler. If you forget to add the new PageCode here, the translations will simply not be returned β€” the client will keep showing skeleton placeholders for that page.

circle-exclamation

Recipe: localized date formatting

Date formatting is a good example of how the translation system handles locale-sensitive rendering without relying on DateTime.ToString or browser locale APIs. The format, the month abbreviations, and the ordinal day suffixes are all driven by translation keys.

Translation data (bis)

The Home page translations include three groups of keys:

  • StandardDateFormat β€” A format template describing the order of date parts. In English: "ShortMonth DayInMonth, Year" (e.g. "Mar 5th, 2026"). In French: "DayInMonth ShortMonth Year" (e.g. "5 Mar 2026").

  • ShortMonth.Jan through ShortMonth.Dec β€” Abbreviated month names. English: "Jan", "Feb"... French: "Jan", "FΓ©v"...

  • DayInMonth.1st through DayInMonth.31st β€” Ordinal day representations. English: "1st", "2nd", "3rd"... French: "1er", "2", "3"...

Parsing the format template

The StandardDateFormat property on the Home translator parses the format string into a list of DatePartFormat tokens:

For English ("ShortMonth DayInMonth, Year"), this produces: [MonthShortName; Separator " "; DayInMonth; Separator ", "; Year].

For French ("DayInMonth ShortMonth Year"), this produces: [DayInMonth; Separator " "; MonthShortName; Separator " "; Year].

Rendering the date

The FormatDate method concatenates the parts by resolving each token against the translation keys:

Usage in views

The view calls FormatDate with the pre-parsed format (typically cached in the component to avoid re-parsing on every render):

The format template is parsed once and reused across multiple dates. Each part β€” month name, day ordinal, separator, order β€” is fully driven by translation keys, so adding a new locale only requires adding translation entries, with no code change.

Last updated