Repository F# setup
open Reified
open Reified.Refinements
open Reified.Result
open Reified.Schema.JsonAdvanced rendering
Most applications never need this page. Read it when the default composition is not the sentence your language wants, or when your localization system, not Reified, should select and render entries.
Message composition
Localized entries are bare predicates:
constraint.presence.present = must be present
constraint.relation.atLeast = must be at least {expected}
constraint.cardinality.between = must have a size between {minimum} and {maximum}
The noun and the actual-value clause are separate entries:
constraint.attribute.default = value
constraint.actual = {message}, but was {actual}
constraint.fullMessage = {attribute} {message}
Rendering one interpreted leaf goes: resolve the predicate, wrap it in constraint.actual if the violation carries
an actual value, and — for fullMessage only — wrap that in constraint.fullMessage with the resolved noun.
Keeping them separate is what makes {actual} optional without an optional-placeholder rule, and it lets a locale
put the value or the noun somewhere English would not:
constraint.actual = reçu {actual} au lieu de « {message} »
constraint.fullMessage = {message} — {attribute}
Named interpolation
Templates use named placeholders with an optional format suffix:
constraint.number.multipleOf = must be a multiple of {divisor:N0}
{{ and }} produce literal braces. Placeholder parsing is identical on every target.
Resource defects degrade rather than throw:
- an unknown placeholder name stays literal, so a translator's typo shows up in the message instead of taking down the request;
- unmatched or malformed braces make that template unusable, and lookup continues down the fallback chain to the neutral English;
- an unknown or unsupported format suffix falls back to ordinary formatting of the value.
Exceptions raised by your lookup, resolver, or formatter callbacks propagate untouched. Those are application defects, not resource misses, and swallowing one would hide the bug in whichever locale nobody exercises.
Operand formatting
Operands are ConstraintValue. The built-in formatter uses the renderer's value culture and honours format
suffixes. Two hooks replace it:
// One uniform callback. Format suffixes are ignored.
renderer |> Renderer.withValues ConstraintValue.render
// The suffix, when you need it.
renderer
|> Renderer.Advanced.withValueFormatting (fun request -> format request.Value request.Format)A list operand — choices on Constraint.oneOf, for instance — is joined through the contextual
constraint.list.* patterns, and each item goes through the formatter.
Lists and groups
Joining is pattern-driven, not hard-coded:
constraint.group.all.pair constraint.group.any.pair constraint.list.pair
constraint.group.all.start constraint.group.any.start constraint.list.start
constraint.group.all.middle constraint.group.any.middle constraint.list.middle
constraint.group.all.end constraint.group.any.end constraint.list.end
pair and end take {first} and {second}; start and middle take {first} and {rest}. The algorithm is
deterministic:
| Items | Result |
|---|---|
| none | "", with no lookup |
| one | the item itself, with no lookup |
| two | pair |
| three or more | combine the last two with end, fold the preceding items right-to-left with middle, and apply start to the first |
Violation.message Renderer.english group
// "must be present, must have a size between 2 and 40 and must be an email address"A language whose joining cannot be expressed as pair/start/middle/end — where the conjunction changes the words of
its members, or the whole group has to be reordered — is not served by these patterns. That case takes
Violation.toMessageTree and owns its own traversal. This is a genuine limit, not a gap to work around with a
cleverer pattern.
Plurals
An entry may declare at most one plural operand. Where one is declared, ordinary lookup tries <key>.one when the
operand is exactly one and <key>.other otherwise, then the bare key — at each contextual level, before moving
outwards:
signup.tags.constraint.cardinality.minimum.other
signup.tags.constraint.cardinality.minimum
signup.constraint.cardinality.minimum.other
signup.constraint.cardinality.minimum
constraint.cardinality.minimum.other
constraint.cardinality.minimum
A bare field-specific entry therefore beats a pluralized model-level entry: the field override is the more deliberate statement.
.one/.other covers the languages that need only those two. Full CLDR category selection belongs to an advanced
resolver.
Advanced resolvers
Renderer.ofLookup owns the entire candidate order and asks a plain lookup for exact keys. When your localization
system wants to select plural categories and render entries itself — ICU, for instance — take a resolver instead.
It receives one request per contextual level:
type MessageRequest =
{ BaseKey: string // encoded contextual key, no plural suffix
Arguments: Map<string, ConstraintValue>
PluralArgument: string option }
let renderer =
Renderer.Advanced.ofResolver (fun request ->
match icu.TryGet request.BaseKey with
| Some entry -> Some (MessageResolution.Rendered (entry.Format request.Arguments))
| None -> None)The answer means:
None— continue to the next, less specific level;MessageResolution.Template template— Reified interpolates and formats it;MessageResolution.Rendered text— you rendered it; Reified never interpolates that text again, so literal braces in it stay literal.
Rendered is final for the entry that was requested. Reified may still use its text as the {message} of
constraint.actual, as a child of a group pattern, or as the {message} of constraint.fullMessage, because
those are separate entries with their own resolution.
A resolver does not replace group traversal. A system that must reorder or reinterpret a whole group takes
Violation.toMessageTree.
Inspecting what will be asked for
Renderer.Advanced.lookupCandidates renderer spec // exact encoded keys, in order
Renderer.Advanced.messageRequests renderer spec // one request per contextual level
Renderer.Advanced.attributeCandidates renderer // encoded attribute-noun keys
Renderer.Advanced.format spec renderer // render any catalogue's entryThese take a MessageFormatSpec: a message identity and arguments (MessageDescriptor) plus the owning
catalogue's neutral fallback and plural operand. That pairing is what lets Schema — or your own catalogue — reuse
every renderer mechanic without Reified.Constraint knowing a single one of its keys.
let spec =
MessageDescriptor.Advanced.ofSegments [ "billing"; "cardExpired" ] Map.empty
|> MessageFormatSpec.Advanced.create "card has expired" None
renderer |> Renderer.Advanced.format speclookupCandidates and the rest return encoded resource keys. Canonical unencoded identity comes only from
MessageDescriptor.key.
Violation.render compatibility
Violation.render remains the resource-free, culture-free path, unchanged. It does not go through Renderer.
| Violation | render |
message Renderer.english |
fullMessage Renderer.english |
|---|---|---|---|
| presence, no actual | value must be present |
must be present |
value must be present |
relation, actual 11 |
expected a value at least 13, but was 11 |
must be at least 13, but was 11 |
value must be at least 13, but was 11 |
| opaque prose | prose unchanged | prose unchanged | noun composed once around the prose |
| group | legacy ; and , or join |
localized group composed once | noun composed once around the group |
Localized English uses bare predicates and composition, which reads better in a sentence. Exact wording
compatibility is promised for render only.