Repository F# setup
open Reified
open Reified.Refinements
open Reified.ResultAdding 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:
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 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:
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)
Reifiedlookup: MessageDescriptor -> stringdescriptor: MessageDescriptorReified.MessageDescriptorA message identity and the operands its template may interpolate. The identity is a parsed relative key such as constraint.cardinality.between or an application's own books.isbn.invalid. A descriptor never carries a document context, an attribute, an encoded resource key, or a plural category: those are rendering-edge facts, and a violation that captured them would stop being path-free comparable data. The representation is private and validated, so rendering has no malformed-descriptor branch. Independently constructed descriptors with the same key and arguments compare equal, as do violations containing them.
Reified.MessageDescriptorModuleReads and builds message identities.
key: MessageDescriptor -> stringThe canonical unencoded key, exactly as authored. This is identity, not a lookup key. The encoded contextual resource keys a lookup receives come from Renderer.Advanced.lookupCandidates. let descriptor = MessageDescriptor.Advanced.create "books.isbn.invalid" Map.empty in MessageDescriptor.key descriptor // "books.isbn.invalid"
yourConjunction: string -> string list -> stringfirst: stringrest: string listMicrosoft.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"
(::)yourDisjunction: string -> string list -> stringrender: MessageTree -> stringtree: MessageTreeReified.MessageTreeA violation projected for an external localization system, retaining its grouping.
LeafOne message.
Reified.MessageLeafOne leaf of a projected message tree. Author-supplied prose on an opaque constraint passes through verbatim unless the author also supplied their own catalogue key. Reified never invents one: a key it made up would promise a lookup that cannot exist.
LocalizedA library failure addressed by catalogue key.
VerbatimAuthor-supplied prose, passed through unchanged.
prose: stringAllMessages for conjoined failures.
first: MessageTreerest: MessageTree listMicrosoft.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 ]
AnyMessages for rejected alternatives.
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 translations = Map.ofList [ "constraint.presence.present", "doit être renseigné" ]
let renderer = Renderer.ofLookup translations.TryFind
translations: Map<string,string>Microsoft.FSharp.Collections.MapModuleContains operations for working with values of type .
ofList: ('Key * 'T) list -> Map<'Key,'T>Returns a new map made from the given bindings. The input list of key/value pairs. The resulting map. let input = [ (1, "a"); (2, "b") ] input |> Map.ofList // evaluates to map [(1, "a"); (2, "b")]
renderer: RendererReified.RendererModuleBuilds renderers and renders messages through them.
ofLookup: MessageLookup -> RendererA renderer backed by any key-to-template lookup. The portable constructor, and the one Fable applications use. let translations : Map<string, string> = Map.empty let renderer = Renderer.ofLookup translations.TryFind
TryFind: string -> string optionLookup an element in the map, returning a Some value if the element is in the domain of the map and None if not. The input key. The mapped value, or None if the key is not in the map. let sample = Map [ (1, "a"); (2, "b") ] sample.TryFind 1 // evaluates to Some "a" sample.TryFind 3 // evaluates to None
Both are immutable values. Scope them per document and field at the call site, not per request:
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
signup: Rendererrenderer: Renderer(|>): '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
Reified.RendererModuleBuilds renderers and renders messages through them.
context: string -> Renderer -> RendererAppends a document, model, form, or component segment. let signup = renderer |> Renderer.context "signup"
violation: ViolationReified.ConstraintModuleCreates, executes, composes, and inspects constraints.
check: Constraint<'value> -> 'value -> Result<unit,Violation>Runs a constraint, returning why the value failed. let retryCount = Constraint.between 0 10 42 |> Constraint.check retryCount |> Result.mapError Violation.render
present: Constraint<^value>Requires a value to be inhabited according to its shape. Whitespace-only text is blank, as are null text, a null or empty collection or map, None, ValueNone, and an empty Nullable. Blankness means .NET's whitespace set plus U+FEFF, which is what lets the rule be exported; see nonBlankPattern. The shape is selected from the return type, so a reusable binding needs its annotation: let requiredName : Constraint<string> = Constraint.present. Applied where the type is already known — inside an annotated rule, or to a schema — no annotation is needed. let requiredName : Constraint<string> = Constraint.present
ErrorRepresents an Error or a Failure. The code failed with a value of 'TError representing what went wrong.
OkRepresents an OK or a Successful result. The code succeeded with a value of 'T.
failwith: string -> 'TThrow a exception. The exception message. Never returns. let failingFunction() = failwith "Oh no" // Throws an exception true // Never reaches this failingFunction() // Throws a System.Exception
Reified.ViolationModuleInspects, traverses, and renders violations.
fullMessage: Renderer -> Violation -> stringRenders a violation as a complete sentence fragment, with the attribute noun composed once. The noun wraps the finished message, never each leaf: a group of three failures still names the field once. With no attribute the contextual constraint.attribute.default supplies the noun, so an unattributed violation reads "Value must be present" rather than borrowing the document context. violation |> Violation.fullMessage (signup |> Renderer.attribute "name") // "Name must be present"
attribute: string -> Renderer -> RendererReplaces the attribute with one segment. Replacement, not append: a form-scoped renderer stays reusable for sibling fields. Schema supplies its typed path through Renderer.Advanced.attributePath instead. signup |> Renderer.attribute "name"
ignore: 'T -> unitIgnore the passed value. This is often used to throw away results of a computation. The value to ignore. ignore 55555 // Evaluates to ()
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:
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:
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
french: Map<string,string>Microsoft.FSharp.Collections.MapModuleContains operations for working with values of type .
ofList: ('Key * 'T) list -> Map<'Key,'T>Returns a new map made from the given bindings. The input list of key/value pairs. The resulting map. let input = [ (1, "a"); (2, "b") ] input |> Map.ofList // evaluates to map [(1, "a"); (2, "b")]
candidates: string -> string -> string -> string list * string listcontext: stringfield: stringkey: stringspec: MessageFormatSpecReified.MessageDescriptorModuleReads and builds message identities.
create: string -> Map<string,ConstraintValue> -> MessageDescriptorParses a relative key, raising for a malformed programmer-authored key. A key written in source is either right or a defect. Failing at construction is what keeps rendering free of a malformed-descriptor branch. MessageDescriptor.Advanced.create "books.isbn.invalid" Map.empty
Reified.MessageDescriptorModule.AdvancedBuilding descriptors from keys that were not written by the calling programmer.
empty: Map<'Key,'T>The empty map. let emptyMap = Map.empty<int, string>
(|>): '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
Reified.MessageFormatSpecModuleReads and builds catalogue-owned rendering metadata.
create: string -> string option -> MessageDescriptor -> MessageFormatSpecBuilds a specification, raising when the plural operand names no argument. let descriptor = MessageDescriptor.Advanced.create "constraint.relation.atLeast" Map.empty in MessageFormatSpec.Advanced.create "must be at least {expected}" None descriptor
Reified.MessageFormatSpecModule.AdvancedBuilding specifications for a catalogue of your own.
Reified.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.
english: 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
NoneThe representation of "No value"
renderer: RendererReified.RendererModuleBuilds renderers and renders messages through them.
ofLookup: MessageLookup -> RendererA renderer backed by any key-to-template lookup. The portable constructor, and the one Fable applications use. let translations : Map<string, string> = Map.empty let renderer = Renderer.ofLookup translations.TryFind
TryFind: string -> string optionLookup an element in the map, returning a Some value if the element is in the domain of the map and None if not. The input key. The mapped value, or None if the key is not in the map. let sample = Map [ (1, "a"); (2, "b") ] sample.TryFind 1 // evaluates to Some "a" sample.TryFind 3 // evaluates to None
context: string -> Renderer -> RendererAppends a document, model, form, or component segment. let signup = renderer |> Renderer.context "signup"
attribute: string -> Renderer -> RendererReplaces the attribute with one segment. Replacement, not append: a form-scoped renderer stays reusable for sibling fields. Schema supplies its typed path through Renderer.Advanced.attributePath instead. signup |> Renderer.attribute "name"
lookupCandidates: Renderer -> MessageFormatSpec -> string listEvery encoded resource key ordinary lookup will try, in order. Includes the selected .one/.other key and the bare key at each contextual level. Use it to test translation coverage for contexts and fields Reified cannot enumerate for you. Renderer.Advanced.lookupCandidates renderer spec |> List.head
Reified.RendererModule.AdvancedReplacing lookup, formatting, or inspecting the exact keys Reified will ask for.
attributeCandidates: Renderer -> string listEvery encoded attribute-noun key, most specific first. let renderer = Renderer.ofLookup (fun _ -> None) in Renderer.Advanced.attributeCandidates renderer // [ "attribute.signup.postcode"; "attribute.postcode" ]
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.