Repository F# setup
open Reified
open Reified.Refinements
open Reified.Result

Adding 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.

Translate the base catalogue first, then override fields whose wording 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:

Verified
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"

Do the same with SchemaMessages.keys if the application parses boundary input:

Verified
open Reified

SchemaMessages.keys |> List.map (fun key -> $"{key} = {SchemaMessages.english[key]}")

Nothing 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 entries determine sentence shape:

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:

Verified
open Reified

let lookup (descriptor: MessageDescriptor) = MessageDescriptor.key descriptor
let yourConjunction first rest = String.concat " and " (first :: rest)
let yourDisjunction first rest = String.concat " or " (first :: rest)

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)

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:

Fragment
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:

Fragment
let renderer = Renderer.ofCurrentCulture resources
services.AddSingleton renderer |> ignore

For a dictionary, a JSON bundle, or Fable:

Verified
let translations = Map.ofList [ "constraint.presence.present", "doit être renseigné" ]

let renderer = Renderer.ofLookup translations.TryFind

Both are immutable values. Scope them per document and field at the call site, not per request:

Verified
let signup = renderer |> Renderer.context "signup"

let violation =
    match Constraint.check Constraint.present "" with
    | Error violation -> violation
    | Ok () -> failwith "unreachable"

violation |> Violation.fullMessage (signup |> Renderer.attribute "name") |> ignore

9. Prove coverage

Base-catalogue coverage needs one predicate, because the skeleton emits .one/.other pairs for pluralized entries instead of the bare key. A filled skeleton therefore satisfies either the bare key or both plural forms, and never both a bare key and its plural pair at once:

Fragment
let coversKey (translations: Map<string, string>) (key: string) =
    match Catalogue.pluralArgument.TryFind key with
    | Some (Some _) ->
        translations.ContainsKey (key + ".one")
        && translations.ContainsKey (key + ".other")
    | _ -> translations.ContainsKey key

[<Fact>]
let ``the French catalogue covers every Reified key`` () =
    let missing =
        Catalogue.keys
        |> List.filter (fun key -> not (coversKey french key))
        |> List.append (
            SchemaMessages.keys
            |> List.filter (fun key -> not (french.ContainsKey key))
        )

    test <@ missing = [] @>

SchemaMessages has no plural entries, so its keys keep the bare ContainsKey check.

This proves base-catalogue coverage only. Contextual overrides such as signup.name.constraint.presence.present are application-authored, not enumerable from Catalogue.keys, so a skeleton never emits them and this test does not check them. 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:

Verified
let french = Map.ofList [ "constraint.presence.present", "doit être renseigné" ]

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 renderer

lookupCandidates 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 the catalogue already handles

You do not need to:

  • 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.