Translations
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 theTranslationsdata structure (including the currentLang).Shopfoo.Shared/Translations.fsβ TheAppTranslationsfacade and page-specific translator classes (Home,Login,Product).Shopfoo.Shared/Remoting.fsβFullContextwithFillTranslations,QueryDataAndTranslations, and theQueryWithTranslationstype alias.Shopfoo.Client/View.fsβ The Elmish loop that owns the translation cache and handles the dispatchedFillTranslationsmessages. Also containsAppViewand theReactDOM.createRootentry point.Shopfoo.Client/Pages/Shared.fsβ TheEnv.IFillTranslationsinterface that pages use to push translations into the cache.
Core types
PageCode, TagCode, and TranslationKey
PageCode, TagCode, and TranslationKeyEvery 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
Translations recordThe raw translation data combines the language with a nested map:
Langβ The language of the translations contained inPages.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
Base class and page-specific classesThe 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
WithPrefix for nested keysSome 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.ConfirmWithPrefixcomposition:"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
Format for parameterized messagesSome 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.
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 β the facadeAppTranslations aggregates the three page translators and tracks which pages have been populated:
Key characteristics:
Immutable β
Fillreturns a newAppTranslationsinstance; the original is unchanged.Lazy recreation β Only page translators whose
PageCodeappears in the incomingTranslationsmap are recreated. Pages that already have data and receive no new data keep their existing translator instance.EmptyPages/PopulatedPagesβ Two computed sets that partition thePageCodevalues based on whether the page translator has any data. These sets drive the piggyback loading mechanism (see below).
Translation cache in FullContext
FullContextThe 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
QueryWithTranslationsThe 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
PrepareQueryWithTranslationsOn 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.
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
TranslationsMissing active patternFor 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:
Page
initβ CallsPrepareQueryWithTranslations, which includes the set ofPageCodes with empty translations (EmptyPages).Client
Cmdβ The remoting call carries theTranslationPagesset to the server.Server handler β Returns the requested translations alongside the response data, filtered by the user's authorized pages and the requested language.
Page
updateβ Receives(data, translations)and dispatchesenv.FillTranslations translations.Root
AppViewupdate β HandlesMsg.FillTranslationsby callingFullContext.FillTranslations, which merges translations into the cache viaAppTranslations.Fill.Views β Access translations through
AppTranslations.Home,.Login,.Productproperties. TheTranslationsMissingpattern guards against rendering before translations are available.Language change β Fetches translations for
PopulatedPagesonly, replaces the entire cache with the new language's data.
Recipe: adding a new translation
Case 1 β Adding a key to an existing PageCode
PageCodeTwo files to edit:
Shopfoo.Home/Data/Translations.fsβ Add a new tuple in the appropriatePageCodegroup:For a parameterized message, use
{0},{1}... placeholders:Shopfoo.Shared/Translations.fsβ Add a member to the corresponding page translator class (e.g.Home,Login, orProduct):For a parameterized message:
For a nested key, add it inside a
WithPrefixblock, 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
PageCodeThis is a more involved change that touches several files across the stack:
Shopfoo.Domain.Types/Translations.fsβ Add a new case to thePageCodeunion:Shopfoo.Home/Data/Translations.fsβ Add a newPageCode.NewPagegroup with its translation tuples.Shopfoo.Shared/Translations.fsβ Create a new translator class inheriting fromBase, then integrate it intoAppTranslations:Shopfoo.Client/Pages/Shared.fsβ No change needed β theEnv.IFillTranslationsinterface is generic overTranslations, notPageCode.Server
{Area}ApiBuilderβ Each API builder defines a staticpagesset that lists thePageCodes its handlers are allowed to return. Add the newPageCodeto the relevant builder(s):This
pagesset is passed asauthorizedPageCodesto eachSecureQueryDataAndTranslationsHandler. If you forget to add the newPageCodehere, the translations will simply not be returned β the client will keep showing skeleton placeholders for that page.
Adding a new PageCode requires updating AppTranslations β a type shared between client and server. Make sure to rebuild both sides after the change. The compiler will guide you: any missing match arm on the new PageCode case will produce a warning.
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.JanthroughShortMonth.Decβ Abbreviated month names. English:"Jan","Feb"... French:"Jan","FΓ©v"...DayInMonth.1stthroughDayInMonth.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