Chapter 13 Localization
SourceTranslating a program's user-facing messages is a problem most languages solve with a gettext-like library: strings live in an external catalogue, translators edit them, your code looks them up at runtime. Silo follows that general shape but replaces the runtime lookup with compile-time code generation — the compiler reads your message catalogue, generates typed words from it, and checks every translation against the fallback catalogue for consistency.
The format is .sil, and it sits next to your .si source files.
A .sil file
A catalogue is a list of message definitions. Each message starts with :name, lists any typed placeholders in braces, then gives the message text ending with ;:
# app.sil
:greeting { name } "Hello, {name}!" ;
@context ( "title" ) @max-length40 @note ( "Keep short — narrow header" )
:settings-title "Settings" ;
# Typed placeholder — Int enables CLDR plural selection
:item-count { count Int }
| one "{count} item"
| _ "{count} items" ;
:price { amount Decimal } "Total: {amount Currency}" ;
:receipt { id Int } "Your receipt ID is {id Hex}" ;
@context ( "button" )
:save-button "Save"
.loading "Saving..."
.done "Saved!" ;
@context ( "label" )
:login "Log in to {app-name}."
.placeholder "user@example.com"
.aria-label "Login email field" ;
A few things to notice:
- Typed placeholders.
{name}is a defaultStr;{count Int}constrains the placeholder's type toInt, which also switches the compiler into CLDR plural-selection mode. Other types —Float,Decimal,Bool,Date,Time,DateTime— are the closed set. Anything else is treated asStr. - Format traits in holes.
{amount Currency}and{id Hex}apply a format trait the same way the format macro does (chapter 13). Locale-aware traits consult theCurrentLocaleaspect. - Annotations —
@context(...),@note(...),@max-length— give translators context. They don't affect the runtime output, but they're part of the catalogue so the same notes follow a message everywhere. - Attributes —
.loading,.done,.placeholder— are companion strings attached to a parent message. Generated as record fields so callers can reach them with.fieldname.
Translations
A locale-specific file is named <stem>.<locale>.sil:
# app.fi-FI.sil
.locale "fi-FI"
:greeting { name } "Hei, {name}!" ;
:settings-title "Asetukset" ;
:item-count { count Int }
| one "{count} tiedosto"
| _ "{count} tiedostoa" ;
:price { amount Decimal } "Yhteensä: {amount Currency}" ;
:save-button "Tallenna"
.loading "Tallennetaan..."
.done "Tallennettu!" ;
A translation file can be partial: it only needs to include the messages it translates. Missing messages fall back to the base catalogue, so nothing breaks if a translator hasn't got to every string.
What the compiler generates
For each message in the fallback catalogue, the compiler generates a typed word. The stack effect mirrors the placeholder types:
:fn greeting ( Str -> Str ) .. . :end # one placeholder, :Str
:fn item-count ( Int -> Str ) .. . : end # one placeholder, :Int
:fn price ( Decimal -> Str ) .. . : end
:fn settings-title ( -> Str ) .. . : end # no placeholders
Calling them works like any other word:
"Alice" greeting # ⌊"Hello, Alice!"⌉ (default locale)
'fi-FI ( Locale .try-from) .unwrap :with CurrentLocale
"Alice" greeting # ⌊"Hei, Alice!"⌉
5 item-count # ⌊"5 tiedostoa"⌉
:end
Locale selection runs through the CurrentLocale aspect — the same aspect that Decimal / Currency / DateTime format traits consult. :with overrides for a scope; without a :with, you get the locale the host installed at startup.
Locale
Locale is a real ICU4X locale value (language, script, region, variant, Unicode extensions) — not a symbol tag. You build one via (Locale .try-from) from a BCP-47 Symbol or Str:
'de-DE ( Locale .try-from) .unwrap pop-> de
"en-GB" ( Locale .try-from) .unwrap pop-> en
Invalid tags return Err at runtime; the compiler can't catch that because tags are usually parameters, not literals. CurrentLocale takes a Locale directly — the aspect's signature is (CurrentLocale Locale).
Selectors
For Int-typed placeholders, the catalogue uses CLDR plural categories — zero, one, two, few, many, other — and the compiler fills in whichever ones each locale requires.
Multi-selector messages (selecting on more than one placeholder) list arms as comma-separated patterns:
:delete-confirm { count Int } { gender }
| one masculine "Delete him?"
| one feminine "Delete her?"
| one _ "Delete it?"
| _ _ "Delete {count} items?" ;
Silo's selectors:
Int— CLDR plural categories.Str— exact match against the arm's string literal.Bool— exhaustivetrue/false._— wildcard, matches anything.
Attributes are methods, each with its own signature
When a message carries attributes, the compiler generates a marker record plus an :impl block where the main message and every attribute is its own method. Each method takes only the placeholders it actually uses — an attribute with different placeholders from the main message gets a different signature entirely, and an attribute with no placeholders is nullary even when the main message takes arguments.
For save-button — no placeholders anywhere:
# Generated from :save-button "Save" .loading "Saving..." .done "Saved!" ;
:record SaveButton :end
:fn save-button ( -> SaveButton ) SaveButton :end
:impl SaveButton
.message ( SaveButton -> Str ) drop "Save" ;
.loading ( SaveButton -> Str ) drop "Saving..." ;
.done ( SaveButton -> Str ) drop "Saved!" ;
:end
Usage:
save-button .message # ⌊"Save"⌉
save-button .loading # ⌊"Saving..."⌉
save-button .done # ⌊"Saved!"⌉
For login — the main message takes {app-name}, the attributes don't take anything. The marker captures app-name so .message can use it; the other two methods ignore the captured value:
# Generated from :login {app-name} "Log in to {app-name}." .placeholder "..." .aria-label "..." ;
:record Login .app-name Str :end
:fn login ( Str -> Login ) Login :end
:impl Login
.message ( Login -> Str )
pop-> l l .app-name "Log in to {}." format ;
.placeholder ( Login -> Str ) drop "user@example.com" ;
.aria-label ( Login -> Str ) drop "Login email field" ;
:end
"Silo" login pop-> l
l .message # ⌊"Log in to Silo."⌉
l .placeholder # ⌊"user@example.com"⌉
l .aria-label # ⌊"Login email field"⌉
The interesting case is when an attribute carries its own placeholders:
:update-available { version Str } "Update to version {version}."
.details { features Str } "New features: {features}"
.link { url Str } "See the changelog at {url}" ;
Here each method's signature picks up only what it uses:
:record UpdateAvailable .version Str :end
:fn update-available ( Str -> UpdateAvailable ) UpdateAvailable :end
:impl UpdateAvailable
.message ( UpdateAvailable -> Str )
pop-> u u .version "Update to version {}." format ;
.details ( Str UpdateAvailable -> Str )
pop-> u pop-> features features "New features: {}" format ;
.link ( Str UpdateAvailable -> Str )
pop-> u pop-> url url "See the changelog at {}" format ;
:end
"1.5" update-available pop-> u
u .message # ⌊"Update to version 1.5."⌉
"SIMD support" u .details # ⌊"New features: SIMD support"⌉
"https://silo.dev/log" u .link # ⌊"See the changelog at https://silo.dev/log"⌉
The main message's {version} is captured once; each attribute asks for its own extras independently. You never pre-compute data an attribute doesn't need.
Compile-time verification
The compiler runs a pass over every .sil file in the project:
- No inventing messages. A locale file can't introduce a message that doesn't exist in the fallback — compile error.
- Placeholder parity. Every placeholder in a locale's translation must match the fallback by name and type.
- Plural coverage. Each locale's required CLDR categories must be present. Finnish needs
oneandother; Russian needsone,few, andmany; and so on. - Attribute fallback. Locale-specific attributes fall back to the base catalogue when omitted; a locale doesn't have to re-translate every attribute.
The point of the pass is that you can ship a new version of your fallback catalogue — add a placeholder, rename a message, add an attribute — and every locale that hasn't been updated to match becomes a compile error. Schema drift is impossible.
Why .sil?
Having the catalogue in its own format lets translators work in a file that doesn't require understanding Silo. The compiler meets them halfway: it checks that what they wrote fits the fallback's shape, and generates the code on their behalf. You don't pay any runtime cost for the abstraction — greeting is a plain word with a generated body, not a dictionary lookup.
Key points
.silfiles sit next to.sisources. Fallback catalogue is<stem>.sil; translations are<stem>.<locale>.sil.- The compiler generates a typed word per message. Placeholder types drive the stack effect; attributes become record fields.
- Typed placeholders: closed set
Str,Int,Float,Decimal,Bool,Date,Time,DateTime. Everything else is aStr. - Selectors cover
Int(CLDR plurals),Str(exact match), andBool(exhaustive). Wildcards via_. Localeis an ICU4X locale value, built via(Locale .try-from)from a BCP-47 tag. TheCurrentLocaleaspect selects the active one.- Compile-time verification: no inventing, placeholder parity, plural coverage, attribute fallback. Schema drift in the fallback is a compile error across every locale.
Next: temporal types — dates, times, calendars, and how they use the locale aspect you just met.