Tsuji 5.0.0
Guide
How to use Taihei Tsuji, feature by feature. Every sample on this page compiles against dev.taihei::taihei-tsuji:5.0.0 from Maven Central, together with the definitions it uses from earlier sections and from Getting started, and the output shown is what it printed on the JVM. The one exception is the Broken family, which is there to show the compile errors it produces.
Languages and fallback
A Language is a BCP 47 language tag, kept in its canonical form. English, Dutch and French are built in as Language.English, Language.Dutch and Language.French; any other language is Language(tag), which throws IllegalArgumentException on an invalid tag. Language.parse does the same without throwing.
import dev.taihei.tsuji.*
import dev.taihei.tsuji.Language.*
given Catalogues = Catalogues(CoreMessages.families)
Language("nl-BE").base == Dutch // true
Language("EN-gb").tag // en-GB
Language.parse("not a tag!") // Left(Not a BCP 47 language tag: 'not a tag!')
Language("de").label.localize(Dutch) // Duits
label is the language's name as a message, shown in the reader's language: hand-made translations for the built-ins, and the platform's names (the JDK's locale data, or Intl.DisplayNames in the browser) for any other.
A Catalogues registry shows a message in a language L by taking the first template that has its key:
- L exactly, for example
zh-Hant-TW; - L without its extensions and private use (
-u-…,-x-…), then with its last subtag removed, step by step:zh-Hant, thenzh; - the fallbacks you configured for L, each walked the same way;
- the family's source language, which always has the key.
object Greetings extends MessageFamily("greeting"):
val hello = message("Hello", Dutch -> "Hallo", Language("de") -> "Guten Tag")
given catalogues: Catalogues = Catalogues(
families = Seq(Greetings),
fallbacks = Map(Language("lb") -> Seq(Language("de"))),
)
Greetings.hello.localize(Language("nl-BE")) // Hallo
Greetings.hello.localize(Language("lb-LU")) // Guten Tag
Greetings.hello.localize(Language("ja")) // Hello
catalogues.fallbackChain(Language("lb-LU"), English) // List(lb-LU, lb, de, English)
Numbers, dates and plurals are always formatted for L, also when the template comes from a fallback. Showing a message never throws.
Message families
A message family is an object that extends MessageFamily(prefix, sourceLanguage). The source language is English unless the family says otherwise. Each message is a val defined with message:
message("text", translations*)is aMessagewithout arguments;message[(count: Int, on: LocalDate)]("template", translations*)is aMessageDefinition. Applied to its arguments, by name or by position, it gives aMessage.
import java.time.LocalDate
import dev.taihei.tsuji.*
import dev.taihei.tsuji.Language.*
object Orders extends MessageFamily("order"):
val cancelled = message("Your order was cancelled", Dutch -> "Uw bestelling werd geannuleerd")
val placed = message[(id: String, on: LocalDate)]("Order {$id} placed on {$on :date}")
object Bestellingen extends MessageFamily("bestelling", sourceLanguage = Dutch):
val geplaatst = message("Bestelling geplaatst", English -> "Order placed")
Orders.cancelled.key.value // order.cancelled
val today = LocalDate.of(2026, 10, 24)
Orders.placed(id = "A-17", on = today)
Orders.placed("A-17", today) // the same, by position
Bestellingen.geplaatst.toString // Bestelling geplaatst: toString uses the source language
Orders.definitions.map(_.key.value) // List(order.cancelled, order.placed)
The key of a message is the prefix, a dot and the name of the val. Keep it stable: renaming the val changes the key, and the key is what travels on the wire and names the message in catalogue files. A message must be the right-hand side of a plain val in the family's body; in a def, a nested object or a class it does not compile. Avoid lazy val: it joins the family only when it is first used, so tests, the export and JSON decoding do not see it before.
A Message is only the key and the argument values. Its translations stay in the family and in the catalogues, so only the reader's language has to be loaded.
Checked when the code compiles
A macro inside the library checks the template and every inline translation written as a literal. It needs no build plugin, so it works with any build tool. The template must be valid MessageFormat 2.0; every variable must be an argument or a .local declared before it; :number, :integer and :plural apply to numbers, :datetime to dates and times, :date and :time to their part; a .match needs a * variant. An argument that a template does not use is a warning, because some languages leave out what others say.
object Broken extends MessageFamily("broken"):
val shipped = message[(count: Int, on: LocalDate)]("Shipped {$count} on {$on :number}")
val missing = message[(count: Int)]("Shipped {$cuont} items")
The template of shipped: :number does not apply to a date (at character 25)
The template of missing: $cuont is not an argument of this message (at character 9)
Arguments, Text and Displayable
Arguments are typed through the type class MessageArg[A]. It has instances for String, Int, Long, Double, Float, Boolean, LocalDate, LocalDateTime, LocalTime, Language (shown as its name in the reader's language), Message, Text and Displayable (shown in the reader's language), and Option and List of these. A None shows as empty text; a list is joined with , . A type of your own goes through one of them with MessageArg.as:
final case class OrderId(value: String)
given MessageArg[OrderId] = MessageArg.as[OrderId, String](_.value, s => Right(OrderId(s)))
object Orders extends MessageFamily("order"):
val cancelled = message("Your order was cancelled", Dutch -> "Uw bestelling werd geannuleerd")
val note = message[(note: Option[String])]("Note: {$note}")
val tags = message[(tags: List[String])]("Tags: {$tags}")
val failed = message[(reason: Displayable)]("The order failed: {$reason}")
val language = message[(language: Language)]("Shown in {$language}")
given Catalogues = Catalogues(Seq(Orders))
Orders.note(note = None).localize(English) // Note:
Orders.tags(tags = List("gift", "express")).localize(English) // Tags: gift, express
Orders.failed(reason = Orders.cancelled).localize(Dutch) // The order failed: Uw bestelling werd geannuleerd
Orders.failed(reason = Text("Disk full", English)).localize(Dutch) // The order failed: Disk full
Orders.language(language = Language("de")).localize(Dutch) // Shown in Duits
The compiler checks the names and types of the arguments, but not the formatting functions applied to a type of your own: Catalogues.verify checks those in your tests.
Text is text in one language that is not a message of a family: an exception's message, or a text a server already translated. It shows as it is, whatever the reader's language. Text.of(throwable) takes the throwable's message, or its class name when it has none.
Message, Text and the 4.x legacy.Message share the supertype Displayable. Each is shown with localize(language) and a given Catalogues in scope.
Catalogues
Translations come from two places, and both are checked the same way:
- Inline, as trailing
Language -> templatepairs ofmessage. They suit a JVM application, a library's own few languages, and tests. Inline translations are code, so in Scala.js they are part of the bundle. - Catalogue files, one JSON file per language, written by translators or exported with the tools module. A browser application with many languages keeps its translations in files.
A catalogue file names its format, its language, and a template per key:
{"format":1,"language":"de","messages":{"shop.welcome":"Willkommen zurück, {$name}","shop.basket":"..."}}
On the JVM, a library or application ships its catalogues as resources named META-INF/taihei-tsuji/<family prefix>/<language tag>.json, for example META-INF/taihei-tsuji/shop/de.json, with the canonical tag (zh-Hant.json). The registry reads a family's file for a language the first time that language is needed, and keeps it. A file holds the keys of its own family only. In Scala.js, the application serves the exported files and loads them, see Loading catalogues.
In a language, a catalogue file wins over an inline translation. In the source language, the template in the code wins.
A catalogue can also be read and added by hand, on either platform. Catalogue.parse never throws; add checks the templates of the families it knows and returns what it refused. A template that fails its checks is dropped, the message falls back, and the problem is kept in catalogues.problems.
val json =
"""{"format":1,"language":"fr","messages":{
| "shop.welcome":"Bon retour, {$name}",
| "shop.basket":"{$count} articles"
|}}""".stripMargin
given catalogues: Catalogues = Catalogues(Seq(Shop))
Catalogue.parse(json) match
case Left(problem) => println(s"refused: ${problem.message}")
case Right(french) =>
catalogues.add(french).foreach(p => println(s"problem: ${p.key.map(_.value)}: ${p.message}"))
Shop.welcome(name = "Ada").localize(French) // Bon retour, Ada
Shop.basket(items = 3).localize(French) // 3 items in your basket
catalogues.languages // Set(French)
problem: Some(shop.basket): $count is not an argument of this message (at character 1)
A catalogue can be built in code with Catalogue(language, messages) and written with toJson. register(families*) adds families to a registry, and onAdded(listener) is called with the language of every catalogue added; it returns the function that unsubscribes. A registry is thread-safe.
MessageFormat 2.0
Templates use the ICU MessageFormat 2.0 syntax, in the dialect this library has always read.
| Syntax | Effect |
|---|---|
{$name} | the variable |
{$n :number} | a number for the reader's locale; style=percent or style=currency |
{$n :integer} | rounded to a whole number |
{$d :datetime dateStyle=long timeStyle=short} | a date and time; styles short, medium, long, full |
{$d :date dateStyle=long} | the date part of a LocalDate or LocalDateTime |
{$t :time} | the time part of a LocalTime or LocalDateTime |
{$x :string} | the value as text |
.input {$n :number} | declares an argument with a function applied |
.local $x = {$n :integer} | a local variable (also .let) |
.match {$n :plural}, .match {$s :select} | chooses a variant when <key> {…}; one variant must be when * {…} |
Plural keys are one, other, or an exact number such as =0 or 0. French counts 0 and 1 as one; English and Dutch only 1. A .match on a Language compares its tag. After the declarations, a simple pattern ends at the line break.
object Inbox extends MessageFamily("inbox"):
val unread = message[(count: Int)](
".match {$count :plural} when =0 {No new messages} when one {One new message} " +
"when * {{$count} new messages}",
French -> (".match {$count :plural} when one {{$count} nouveau message} " +
"when * {{$count} nouveaux messages}"),
)
val invited = message[(host: String, role: String)](
".match {$role :select} when admin {{$host} made you an administrator} " +
"when * {{$host} invited you}"
)
val total = message[(amount: Double)](
".local $rounded = {$amount :integer}\nAbout {$rounded} in total"
)
given Catalogues = Catalogues(Seq(Inbox))
Inbox.unread(count = 0).localize(English) // No new messages
Inbox.unread(count = 1).localize(English) // One new message
Inbox.unread(count = 5).localize(English) // 5 new messages
Inbox.unread(count = 0).localize(French) // 0 nouveau message
Inbox.invited(host = "Ada", role = "admin").localize(English) // Ada made you an administrator
Inbox.invited(host = "Ada", role = "viewer").localize(English) // Ada invited you
Inbox.total(amount = 41.7).localize(English) // About 42 in total
MessageFormat2 also formats and checks a template on its own. format never throws, and a variable without a value shows as $name. validate reports syntax errors, unknown functions, a .match without a * variant, and text after the pattern; which variables exist is checked by a message definition, not here.
val tmpl = ".match {$count :plural} when one {You have one item} when * {You have {$count} items}"
MessageFormat2.format(tmpl, Map("count" -> 1), "en") // You have one item
MessageFormat2.format(tmpl, Map("count" -> 5), "en") // You have 5 items
MessageFormat2.validate("Hello {$name") // List(at 6: a placeholder is not closed with })
MessageFormat2.validate(".match {$n :plural} when one {One}")
// List(at 0: .match needs a default variant with * for every selector)
Numbers, dates and times
Formatting happens inside templates. On the JVM it runs through java.text.NumberFormat and java.time.format.DateTimeFormatter, in Scala.js through the browser's Intl.NumberFormat and Intl.DateTimeFormat. A bare en, nl or fr formats as en-US, nl-NL and fr-FR; any other valid tag as written; a tag the platform cannot use formats as en-US.
object Report extends MessageFamily("report"):
val summary = message[(share: Double, visitors: Long, day: LocalDate, at: LocalDateTime, opens: LocalTime)](
"{$share :number style=percent} of {$visitors :number} visitors on {$day :date dateStyle=full}; " +
"updated {$at :datetime dateStyle=short timeStyle=short}; opens at {$opens :time}"
)
given Catalogues = Catalogues(Seq(Report))
val summary = Report.summary(
share = 0.256, visitors = 12345L, day = LocalDate.of(2026, 10, 24),
at = LocalDateTime.of(2026, 10, 24, 14, 30), opens = LocalTime.of(9, 0),
)
for l <- Seq(English, Dutch, French, Language("de-CH")) do println(s"${l.tag}: ${summary.localize(l)}")
The family has only an English template, so every language falls back to it, and each formats the values its own way:
en: 26% of 12345 visitors on Saturday, October 24, 2026; updated 10/24/26, 2:30 PM; opens at 9:00:00 AM
nl: 26% of 12345 visitors on zaterdag 24 oktober 2026; updated 24-10-2026 14:30; opens at 09:00:00
fr: 26 % of 12345 visitors on samedi 24 octobre 2026; updated 24/10/2026 14:30; opens at 09:00:00
de-CH: 26% of 12345 visitors on Samstag, 24. Oktober 2026; updated 24.10.26, 14:30; opens at 09:00:00
What to know about 5.0.0:
:numbershows a whole number within the range of anIntas its digits, without grouping separators (12345), and any other number with two decimals in the reader's format (12.345,68in Dutch).style=percentrounds to a whole percentage.style=currencyalways formats US dollars: on the JVM in the US format ($1,234.50), in Scala.js in the reader's format (US$ 1.234,50in Dutch).- On the JVM a
longorfulltimeStyleon a value without a time zone formats asmedium; Scala.js prints the runtime's zone instead. - Small differences between the JVM and the browser come from their locale data, such as the comma in
24-10-2026, 14:30in Dutch in Scala.js.
Loading catalogues, and errors
On the JVM
Nothing needs to be configured: files on the classpath are read when a language is first needed. ClasspathCatalogues.read(family, language) reads one file directly: None when there is none, Some(Left(problem)) when it cannot be read, and Some(Right(catalogue)) otherwise.
ClasspathCatalogues.read(Shop, Dutch).map(_.map(_.messages.size)) // Some(Right(2))
A registry remembers the files it found, and of the languages without a file the latest 4096. Narrow a language that comes from a request header to the languages you support before you show messages in it, or each new tag costs a lookup per family.
In Scala.js
The bundle holds the definitions and the source-language templates: one language, not all of them. The application serves the per-language files that the export writes, and loads a language with CatalogueLoader.load(catalogues, language, url). It uses the global fetch, takes a given ExecutionContext, and returns a Future with the problems it found, which never fails. Until a language arrives, its messages show through the fallback chain; onAdded says when to show them again.
import scala.concurrent.ExecutionContext.Implicits.global
import dev.taihei.tsuji.*
import dev.taihei.tsuji.Language.*
val catalogues = Catalogues(Seq(Shop) ++ CoreMessages.families)
given Catalogues = catalogues
def render(language: Language): Unit =
println(Shop.welcome(name = "Ada").localize(language))
def switchTo(language: Language): Unit =
render(language) // through the fallback chain until loaded
CatalogueLoader.load(catalogues, language, s"/i18n/${language.tag}.json").foreach: problems =>
problems.foreach(p => println(s"could not load ${language.tag}: ${p.message}"))
val stop = catalogues.onAdded(language => render(language))
The library has no dependency on a user interface or an effect library: a Laminar or other front end turns the onAdded callback into its own signal. On a language switch the application fetches the next file; it may prefetch one, the library does not.
Errors
Showing a message never throws: a missing or broken translation falls back, and the source language always has the key. What went wrong is a CatalogueProblem, with a message, an optional language and an optional key. Input text in a problem is escaped and cut short, so it is safe to log. You find them in what Catalogue.parse, add, CatalogueLoader.load and verify return, and in catalogues.problems (the first 1000).
Catalogue.parse("""{"format":2,"language":"fr","messages":{}}""") // Left(format 2 is not format 1)
Catalogue.parse("not json") // Left(not JSON: (expected 'null'))
Checking catalogues in tests
Whether a catalogue file is complete is checked in your tests, not by the compiler: checking files during compilation would make the build read files a compiler cannot track. Catalogues.verify works on any platform with the families and catalogues you give it; ClasspathCatalogues.verify reads the files on the JVM classpath. Both return the problems, and an empty list means all is well.
They check every template in the code and in the catalogues against the definition of its key, that no catalogue has a key without a definition, that every required language has every key (inline or in a file; other languages may be partial and fall back), and that prefixes and keys are unique across families.
import dev.taihei.tsuji.*
import dev.taihei.tsuji.Language.*
import zio.test.*
object ShopSpec extends ZIOSpecDefault:
def spec = suite("shop messages")(
test("the catalogue files are complete and valid") {
assertTrue(ClasspathCatalogues.verify(Seq(Shop), Seq(Dutch, Language("de")), required = Set(Dutch)).isEmpty)
},
test("Dutch reads as Dutch") {
given Catalogues = Catalogues(Seq(Shop))
assertTrue(Shop.basket(items = 0).localize(Dutch) == "Je mandje is leeg")
},
)
A failing check names the language and the key:
val dutch = Catalogue(Dutch, Map(
MessageKey.parse("shop.welcome").toOption.get -> "Welkom terug, {$name}",
MessageKey.parse("shop.farewell").toOption.get -> "Tot ziens",
))
Catalogues.verify(Seq(Shop), Seq(dutch), required = Set(Dutch))
.foreach(p => println(s"${p.language.map(_.tag)} ${p.key.map(_.value)}: ${p.message}"))
Some(nl) Some(shop.farewell): no message has this key
Some(nl) Some(shop.basket): missing in a required language
Exporting catalogues
taihei-tsuji-tools has one program, dev.taihei.tsuji.tools.ExportCatalogues. It finds the families on the classpath, those of libraries such as CoreMessages included, and writes one merged file per language, <out>/<tag>.json. The file of the source language is the one to hand to translators; the others hold the catalogue files on the classpath over the inline translations, ready for a browser application to load.
ExportCatalogues --out <dir> [--scan <package>]... [--family <object>]...
--scan finds every family object in a package; --family names one. Templates that fail their checks are left out and printed, and make the exit status 1; a usage error exits with 2. It runs on the JVM, in a module whose classpath holds the families: for a cross-built application, the JVM side of its shared code. A family defined only in Scala.js code cannot be exported.
// Mill: the tools on the run classpath of the module that holds the families
def runMvnDeps = Seq(mvn"dev.taihei::taihei-tsuji-tools:5.0.0")
// ./mill app.runMain dev.taihei.tsuji.tools.ExportCatalogues --out out/tsuji --scan com.example --scan dev.taihei.tsuji
// sbt: run it forked, so that its classpath is the application's
libraryDependencies += "dev.taihei" %% "taihei-tsuji-tools" % "5.0.0"
Compile / run / fork := true
// sbt "runMain dev.taihei.tsuji.tools.ExportCatalogues --out target/tsuji --scan com.example --scan dev.taihei.tsuji"
Run against the Shop family of Getting started, in a package com.example.shop and with an inline French translation of welcome:
2 messages in de: out/tsuji/de.json
2 messages in en: out/tsuji/en.json
1 messages in fr: out/tsuji/fr.json
2 messages in nl: out/tsuji/nl.json
{
"format" : 1,
"language" : "fr",
"messages" : {
"shop.welcome" : "Bon retour, {$name}"
}
}
Add --scan dev.taihei.tsuji to export the Dutch and French catalogues of CoreMessages with your own, which a browser application needs to show them in those languages.
Messages as JSON
A Message travels as its key and its typed arguments, and by default its text in the source language. A number is a number, a date an ISO 8601 string, and a language its tag. The codecs are zio-json's.
import zio.json.*
given Catalogues = Catalogues(Seq(Orders))
val shipped = Orders.shipped(count = 3, on = LocalDate.of(2026, 10, 23))
shipped.toJson
// {"key":"order.shipped","args":{"count":3,"on":"2026-10-23"},"text":"3 items shipped on October 23, 2026"}
shipped.toJson(using Message.encoder(withText = false))
// {"key":"order.shipped","args":{"count":3,"on":"2026-10-23"}}
shipped.toJson.fromJson[Message].map(_.localize(Dutch))
// Right(3 items shipped on 23 oktober 2026)
"""{"key":"order.refunded","args":{},"text":"Refund sent"}""".fromJson[Message].map(_.localize(Dutch))
// Right(Refund sent)
"""{"key":"order.shipped","args":{"count":"three","on":"2026-10-23"}}""".fromJson[Message]
// Left((argument count of order.shipped: expected a number, not a string))
(Text("Disk full", English): Displayable).toJson
// {"text":"Disk full","language":"en"}
Reading a message needs its definition, to read the arguments with their types: the decoder takes a given Catalogues and looks the key up among the registry's families. Register every family whose messages you read. A key the registry does not know reads as its text, shown as it is in every language, and is an error without one. A missing argument is an error unless it is an Option. The 4.x shape is still read, as a legacy message, so stored 4.x JSON and a 4.x peer keep working.
A key is a contract. Never reuse a key for a different set of arguments. On the wire, adding an argument needs a new key unless it is an Option, which an older writer leaves out and a reader sees as None; removing or retyping one needs a new key. In a family that a library publishes, the arguments of a definition are its API: within a major version, add a new val with a new key instead of changing one.
Ready-made messages
CoreMessages holds families of common messages, with English as the source language and Dutch and French as catalogue files in the jar: General, Authentication, Authorization, Validation, Exceptions, and Languages, which Language.label uses. CoreMessages.families lists them.
given Catalogues = Catalogues(CoreMessages.families)
CoreMessages.Validation.stringMinLength(min = 8).localize(Language.Dutch)
// lengte moet minimaal 8 zijn
CoreMessages.Validation.formatErrors(Map(
"email" -> List(CoreMessages.Validation.stringValidEmail),
"name" -> List(CoreMessages.Validation.stringShouldNotBeBlank),
)).localize(Language.French)
// Échec de validation: email (Doit être une adresse e-mail valide), name (ne doit pas être vide)
In Scala.js the Dutch and French files reach the browser through the export, like your own; until they are loaded, these messages show in English. The full list of members is in the CoreMessages reference.
The legacy.Message bridge
The 4.x Message, which carried every translation in itself, stays in 5.x unchanged and deprecated as dev.taihei.tsuji.legacy.Message, until 6.0.0. It is a Displayable, so 4.x messages and the new ones are shown side by side while you move to families. Import it explicitly: an explicit import wins over the wildcard's Message.
//> using options -Wconf:cat=deprecation&origin=dev\.taihei\.tsuji\.legacy\..*:s
import dev.taihei.tsuji.*
import dev.taihei.tsuji.Language.*
import dev.taihei.tsuji.legacy.Message // an explicit import wins over the wildcard
object Account extends MessageFamily("account"):
val saved = message("Saved", Dutch -> "Opgeslagen")
given Catalogues = Catalogues(Seq(Account))
val hello: Message = Message.withNamedArgs(English -> "Hello {$name}", Dutch -> "Hallo {$name}")("name" -> "Ada")
hello.translate(Dutch) // Hallo Ada: the 4.x call, unchanged
val shown: List[Displayable] = List(hello, Account.saved)
shown.map(_.localize(Dutch)).mkString(" / ") // Hallo Ada / Opgeslagen
The -Wconf option silences the deprecation warnings of the legacy package while you migrate. legacy.MessageArg is the 4.x union type of argument values. A message read from 4.x JSON is a legacy message under the key legacy. See Migrating for the steps.
Limits on untrusted input
Catalogue files and JSON messages may come from outside your code: a translator, a tenant, a client. Tsuji bounds what it reads and what it produces, and refuses or falls back past a bound instead of running out of memory or time:
- A catalogue is at most 4M characters (a classpath file at most 16 MB, UTF-8 only), 100000 messages and 16 levels of JSON nesting; a template in it at most 64K characters. Entries past the template bound are left out and listed as problems.
- A catalogue in which more than 16 keys share one hash code is refused. Real keys almost never share one, and a map of keys that do costs time out of proportion to its size. The same bound applies to the names in one object of the kept arguments of a message with an unknown key.
- Messages nest at most 8 deep, a list argument holds at most 10000 elements, and the kept arguments of a message with an unknown key are at most 16 levels and 100000 values.
- A 4.x message brings at most 1000 templates and 1000 arguments, none of them longer than 64K characters.
- One template formats to at most a million characters, and one message with its nested messages, list elements and fallbacks to at most four million; past that it shows its own template.
- On the JVM a registry remembers the latest 4096 languages without a file; narrow a language from a request to the ones you support.
val tooLong = " " * (4 * 1024 * 1024 + 1)
// colliding: a catalogue in German whose 17 keys share one hash code
Catalogue.parse(colliding) // Left(de: more than 16 keys of a catalogue share a hash code)
Catalogue.parse(tooLong) // Left(a catalogue is at most 4194304 characters)
taihei-i18n 4.0.1 and earlier had no such bounds for Message JSON; see advisory GHSA-6h29-v54g-8337. Report a security issue to security@taihei.dev, as the security policy describes.