Repository F# setup
open Reified
open Reified.Refinements
open Reified.Result
open Reified.Schema.JsonAdding a language
Adding a language means supplying entries for keys Reified already publishes. The catalogue is available at runtime, so the resource file, the coverage test, and this page never have to be kept in sync by hand.
The order below is the one that pays off: get the base catalogue rendering in the new language first, then override the handful of fields whose wording actually differs.
1. Generate the skeleton
Catalogue.keys is every key Reified can produce, including the composition and joining entries.
Catalogue.english and Catalogue.pluralArgument give the starting text and which entries take .one/.other.
A short script writes a starting file for a translator:
open Reified
let skeleton () =
Catalogue.keys
|> List.collect (fun key ->
let english = Catalogue.english[key]
match Catalogue.pluralArgument[key] with
// An entry that declares an operand gets both forms; the translator deletes one if the language
// needs only a single form.
| Some _ -> [ $"{key}.one = {english}"; $"{key}.other = {english}" ]
| None -> [ $"{key} = {english}" ])
|> String.concat "\n"
Reifiedskeleton: unit -> stringReified.CatalogueThe built-in message catalogue, and what a translator must cover. Entries are bare predicates: "must be at least {expected}", not a whole sentence. The attribute noun and the optional actual-value clause are separate composition entries (constraint.fullMessage and constraint.actual), so a locale can place either in its own order and {actual} needs no optional-placeholder rule. The composition and joining entries — constraint.attribute.default, constraint.actual, constraint.fullMessage, constraint.group.all.*, constraint.group.any.*, and constraint.list.* — are listed here too, because a language that reorders them must be able to find them.
keys: string listEvery message key Reified can produce, including the composition and joining entries. Enumerate this to test that a translation covers the base catalogue. Catalogue.keys |> List.filter (fun key -> not (translations.ContainsKey key))
(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
Microsoft.FSharp.Collections.ListModuleContains operations for working with values of type . Operations for collections such as lists, arrays, sets, maps and sequences. See also F# Collection Types in the F# Language Guide.
collect: ('T -> 'U list) -> 'T list -> 'U listFor each element of the list, applies the given function. Concatenates all the results and return the combined list. The function to transform each input element into a sublist to be concatenated. The input list. The concatenation of the transformed sublists. For each positive number in the array we are generating all the previous positive numbers [1..4] |> List.collect (fun x -> [1..x]) The sample evaluates to [1; 1; 2; 1; 2; 3; 1; 2; 3; 4] (added extra spaces for easy reading)
key: stringenglish: stringenglish: Map<string,string>The neutral English template for each entry, used when no resource resolves. Catalogue.english.["constraint.presence.present"] // "must be present"
Item: stringLookup an element in the map. Raise KeyNotFoundException if no binding exists in the map. The input key. Thrown when the key is not found. The value mapped to the key. let sample = Map [ (1, "a"); (2, "b") ] sample.[1] // evaluates to "a" sample.[3] // throws KeyNotFoundException
pluralArgument: Map<string,string option>The argument each entry may be pluralized on, when it declares one. At most one per entry. A translation may supply <key>.one and <key>.other for these; every other entry takes a single form. Catalogue.pluralArgument.["constraint.cardinality.minimum"] // Some "minimum"
Item: string optionLookup an element in the map. Raise KeyNotFoundException if no binding exists in the map. The input key. Thrown when the key is not found. The value mapped to the key. let sample = Map [ (1, "a"); (2, "b") ] sample.[1] // evaluates to "a" sample.[3] // throws KeyNotFoundException
SomeThe representation of "Value of type 'T" The input value. An option representing the value.
NoneThe representation of "No value"
Microsoft.FSharp.Core.StringModuleFunctional programming operators for string processing. Further string operations are available via the member functions on strings and other functionality in System.String and System.Text.RegularExpressions types. Strings and Text
concat: string -> string seq -> stringReturns a new string made by concatenating the given strings with separator sep, that is a1 + sep + ... + sep + aN. The separator string to be inserted between the strings of the input sequence. The sequence of strings to be concatenated. A new string consisting of the concatenated strings separated by the separation string. Thrown when strings is null. let input1 = ["Stefan"; "says:"; "Hello"; "there!"] input1 |> String.concat " " // evaluates "Stefan says: Hello there!" let input2 = [0..9] |> List.map string input2 |> String.concat "" // evaluates "0123456789" input2 |> String.concat ", " // evaluates "0, 1, 2, 3, 4, 5, 6, 7, 8, 9" let input3 = ["No comma"] input3 |> String.concat "," // evaluates "No comma"
Do the same with SchemaMessages.keys if the application parses boundary input:
open Reified
SchemaMessages.keys |> List.map (fun key -> $"{key} = {SchemaMessages.english[key]}")
ReifiedReified.SchemaMessagesThe message keys Schema's own failures render through. Parse, boundary-supply, and structural failures are closed identities with schema.* keys and neutral English fallbacks. Constructor failures and custom errors carrying authored prose stay verbatim: Schema has no catalogue entry for text an application wrote. Entries are bare predicates like the constraint catalogue's, so SchemaErrors.messages and SchemaErrors.fullMessages compose the attribute noun exactly once in either case.
keys: string listEvery Schema message key, with the arguments its template may interpolate. Use it the way Catalogue.keys is used: to test that a translation covers Schema too. SchemaMessages.keys |> List.filter (translations.ContainsKey >> not)
(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
Microsoft.FSharp.Collections.ListModuleContains operations for working with values of type . Operations for collections such as lists, arrays, sets, maps and sequences. See also F# Collection Types in the F# Language Guide.
map: ('T -> 'U) -> 'T list -> 'U listBuilds a new collection whose elements are the results of applying the given function to each of the elements of the collection. The function to transform elements from the input list. The input list. The list of transformed elements. let inputs = [ "a"; "bbb"; "cc" ] inputs |> List.map (fun x -> x.Length) Evaluates to [ 1; 3; 2 ]
key: stringNothing in this step is Reified-specific beyond the three maps. Emit .resx, JSON, .po, or whatever your
localization pipeline already reads.
2. Translate the predicates
Entries are bare predicates. Write them as fragments that a noun can precede, not as complete sentences:
constraint.presence.present = doit être renseigné
constraint.relation.atLeast = doit être au moins {expected}
constraint.cardinality.between = doit avoir une taille comprise entre {minimum} et {maximum}
Placeholder names are fixed by the catalogue; the table in the key catalogue lists them per key. An unknown name renders literally rather than throwing, so a typo shows up in the message.
3. Translate the composition entries
These four decide sentence shape, and they are where most of a language's character lives:
constraint.attribute.default = valeur
constraint.actual = {message}, mais était {actual}
constraint.fullMessage = {attribute} {message}
Reorder them freely. A language that puts the actual value first, or the noun last, changes only these entries — not the twenty-five predicates:
constraint.actual = reçu {actual} au lieu de « {message} »
constraint.fullMessage = {message} — {attribute}
{message} and {attribute} hold text Reified has already rendered. They are substituted as-is and never
re-interpolated, so braces inside them stay literal.
4. Translate the joining patterns
Groups and lists join through pair, start, middle, and end:
constraint.group.all.pair = {first} et {second}
constraint.group.all.start = {first}, {rest}
constraint.group.all.middle = {first}, {rest}
constraint.group.all.end = {first} et {second}
constraint.group.any.pair = {first} ou {second}
constraint.group.any.end = {first} ou {second}
Three or more items combine the last two with end, fold the preceding items right-to-left with middle, and
apply start to the first. Two items use pair. One item is rendered alone with no lookup at all, so there is no
pattern to write for the singular case.
If your language's joining cannot be expressed this way — the conjunction changes the words of its members, or the
group has to be reordered as a whole — patterns are the wrong tool. Project Violation.toMessageTree and own the
traversal:
let rec render tree =
match tree with
| MessageTree.Leaf (MessageLeaf.Localized descriptor) -> lookup descriptor
| MessageTree.Leaf (MessageLeaf.Verbatim prose) -> prose
| MessageTree.All (first, rest) -> yourConjunction (render first) (List.map render rest)
| MessageTree.Any (first, rest) -> yourDisjunction (render first) (List.map render rest)That is a required path for those languages, not a fallback for a pattern you have not found yet.
5. Name the fields
Attribute nouns live under their own prefix and resolve from most to least specific:
attribute.signup.address.postcode = Le code postal de facturation
attribute.address.postcode = Le code postal
attribute.postcode = Le code postal
Name the ones that matter and let the rest humanize. Humanization only ever applies to a raw segment name — a resolved resource value is returned byte-for-byte, including its casing and any leading or trailing whitespace the translator wanted.
6. Override only what differs
Everything above is context-free. Add specificity only where the wording genuinely changes:
signup.constraint.presence.present = est obligatoire pour l'inscription
signup.name.constraint.presence.present = veuillez indiquer votre nom
Lookup removes rightmost specificity one segment at a time, so an override costs one entry and nothing else has to
change. The message identity is never truncated: books.isbn.invalid never degrades to books.isbn.
7. Plurals
Entries that declare an operand accept .one and .other:
constraint.cardinality.minimum.one = doit contenir au moins {minimum} élément
constraint.cardinality.minimum.other = doit contenir au moins {minimum} éléments
.one is selected when the operand is exactly one; .other otherwise. The plural key is tried before the bare key
at the same contextual level, which means a bare field-specific entry still beats a pluralized model-level one.
Two forms are all ordinary lookup does. A language with more categories — or one where the category depends on more than the value — takes an advanced resolver:
let renderer =
Renderer.Advanced.ofResolver (fun request ->
match request.PluralArgument, icu.TryGet request.BaseKey with
| Some operand, Some entry ->
Some (MessageResolution.Rendered (entry.Format(request.Arguments, cldrCategory operand request.Arguments)))
| _, Some entry -> Some (MessageResolution.Rendered (entry.Format request.Arguments))
| _, None -> None)Reified keeps contextual fallback and violation composition; the resolver owns category selection and the entry's own rendering.
8. Wire it up
For .NET resources, one renderer at the composition root:
let renderer = Renderer.ofCurrentCulture resources
services.AddSingleton renderer |> ignoreFor a dictionary, a JSON bundle, or Fable:
let renderer = Renderer.ofLookup translations.TryFindBoth are immutable values. Scope them per document and field at the call site, not per request:
let signup = renderer |> Renderer.context "signup"
violation |> Violation.fullMessage (signup |> Renderer.attribute "name")
errors |> SchemaErrors.fullMessages signup9. Prove coverage
Base-catalogue coverage is a one-line test:
[<Fact>]
let ``the French catalogue covers every Reified key`` () =
let missing =
Catalogue.keys @ SchemaMessages.keys
|> List.filter (fun key -> not (french.ContainsKey key))
test <@ missing = [] @>Reified cannot enumerate your contexts and fields — it has never seen them. For contextual coverage, enumerate the ones you care about and ask the renderer exactly what it will look up:
let candidates context field key =
let spec =
MessageDescriptor.Advanced.create key Map.empty
|> MessageFormatSpec.Advanced.create Catalogue.english[key] None
let renderer =
Renderer.ofLookup french.TryFind
|> Renderer.context context
|> Renderer.attribute field
Renderer.Advanced.lookupCandidates renderer spec,
Renderer.Advanced.attributeCandidates rendererlookupCandidates returns the exact encoded keys in order, including the selected .one/.other key at each
level; attributeCandidates returns the noun keys. Assert that at least one of each resolves.
For a pluralized entry, pass an argument map with a representative operand — the selected suffix depends on the
value, so 1 and 3 give you the .one and .other candidates respectively.
What a translator never has to do
- reproduce the key list by hand — it is
Catalogue.keys; - write an optional-value template —
{actual}is a separate composition entry; - repeat the field name in every predicate — the noun composes once, around the finished message;
- handle a missing entry — every key falls back through the contextual chain to neutral English;
- worry about a field named with a dot or a bracket — segments are encoded before joining.