The guide
Languages and fallback, message families and their arguments, catalogues, MessageFormat 2.0, formatting, loading in the browser, testing, the export tool, JSON, the ready-made messages and the limits on untrusted input.
Library
Taihei Tsuji is a type-safe internationalization library for Scala 3, on the JVM and Scala.js. You define the messages your application shows as typed message families, the compiler checks every template and its arguments, and translations come from per-language catalogues. Templates are ICU MessageFormat 2.0, and numbers, dates and plurals are formatted for the reader's language.
It is for Scala 3 applications and libraries that show text to people in more than one language: a server that answers in the language of its client, a browser application built with Scala.js, or a library that ships its own translated messages. It solves the usual problem of messages kept as strings: an argument name with a typo, a date formatted as a number or a missing translation shows up only when a user sees it. With Tsuji the first two do not compile, a test finds the third, and showing a message never throws.
dev.taihei::taihei-tsuji · 5.0.0 on Maven Central · Scala 3.9 or later · Apache License 2.0
A message family groups messages under a key prefix. Its arguments are named and typed, and a macro in the library checks the templates when the code compiles:
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 shipped = message[(count: Int, on: LocalDate)](
".match {$count :plural} when one {One item shipped on {$on :date dateStyle=long}} " +
"when * {{$count} items shipped on {$on :date dateStyle=long}}",
Dutch -> (".match {$count :plural} when one {Eén artikel verzonden op {$on :date dateStyle=long}} " +
"when * {{$count} artikels verzonden op {$on :date dateStyle=long}}"),
)
given Catalogues = Catalogues(Seq(Orders))
val shipped = Orders.shipped(count = 3, on = LocalDate.of(2026, 10, 23))
shipped.localize(English) // 3 items shipped on October 23, 2026
shipped.localize(Dutch) // 3 artikels verzonden op 23 oktober 2026
shipped.localize(Language("de")) // 3 items shipped on 23. Oktober 2026
There is no German translation, so the last line falls back to the English template, and formats the date in German.
The name
The tsuji (通詞, romanized tsūji) were the official interpreters of Nagasaki in the Edo period. The word means interpreter. Its first character, 通, carries the sense of passing through and connecting; the second, 詞, means words.
From 1641 until the middle of the 19th century, the Dutch trading post on Dejima, an island in Nagasaki harbour, was the only place in Japan open to Westerners, and the Dutch were the only Westerners allowed to trade there. The official contact between the Dutch and the Japanese authorities went through these interpreters.
The Taihei Framework names its libraries after the Edo period. This library does the work of an interpreter for an application: it carries each message into the language of whoever reads it. The library writes the name without the long vowel mark, as Tsuji. Up to 4.x it was called taihei-i18n.
Sources: Wiktionary, 通事 and its spelling 通詞; Wikipedia, Dejima.
Taihei Tsuji needs Scala 3.9 or later, and on the JVM Java 21 or later: 5.0.0 uses a JDK method from Java 19, and on Java 17 formatting a number or a date for Dutch (nl) fails. It is published for the JVM (taihei-tsuji_3) and for Scala.js (taihei-tsuji_sjs1_3); the two have the same API.
// Mill
mvn"dev.taihei::taihei-tsuji:5.0.0" // JVM
mvn"dev.taihei::taihei-tsuji::5.0.0" // Scala.js
// sbt
libraryDependencies += "dev.taihei" %% "taihei-tsuji" % "5.0.0" // JVM
libraryDependencies += "dev.taihei" %%% "taihei-tsuji" % "5.0.0" // Scala.js, and cross-built projects
// scala-cli
//> using dep dev.taihei::taihei-tsuji:5.0.0 // JVM
//> using dep dev.taihei::taihei-tsuji::5.0.0 // with //> using platform scala-js
On the JVM, numbers and dates are formatted through java.text and java.time; on Scala.js through the browser's Intl API, with java.time from scala-java-time. The library depends on zio-json for its JSON codecs, and on nothing for the user interface or for effects.
taihei-tsuji-tools, for the JVM only, holds one program, ExportCatalogues. It finds the message families on your application's classpath and writes one catalogue file per language: the source language for translators, and the other languages for a browser application to load. It is an ordinary program you run, not a build plugin, so it belongs on a run classpath, not in your application:
// Mill
def runMvnDeps = Seq(mvn"dev.taihei::taihei-tsuji-tools:5.0.0")
// sbt
libraryDependencies += "dev.taihei" %% "taihei-tsuji-tools" % "5.0.0"
See Exporting catalogues in the guide for how to run it.
A small JVM application, from the definition of its messages to their German and Dutch translations. It runs as it is with scala-cli; in Mill or sbt, the Scala file goes in your sources and the JSON files in your resources.
//> using scala 3.9.0
//> using jvm 21
//> using dep dev.taihei::taihei-tsuji:5.0.0
//> using resourceDir resources
import dev.taihei.tsuji.*
import dev.taihei.tsuji.Language.*
object Shop extends MessageFamily("shop"):
val welcome = message[(name: String)]("Welcome back, {$name}")
val basket = message[(items: Int)](
".match {$items :plural} when 0 {Your basket is empty} when one {One item in your basket} " +
"when * {{$items} items in your basket}"
)
The family's prefix is shop and its source language English, so the keys are shop.welcome and shop.basket, and the templates in the code are the English text.
On the JVM, a catalogue is a resource at META-INF/taihei-tsuji/<family prefix>/<language tag>.json. The Dutch one, resources/META-INF/taihei-tsuji/shop/nl.json:
{"format":1,"language":"nl","messages":{
"shop.welcome":"Welkom terug, {$name}",
"shop.basket":".match {$items :plural} when 0 {Je mandje is leeg} when one {Eén artikel in je mandje} when * {{$items} artikels in je mandje}"
}}
And the German one, resources/META-INF/taihei-tsuji/shop/de.json:
{"format":1,"language":"de","messages":{
"shop.welcome":"Willkommen zurück, {$name}",
"shop.basket":".match {$items :plural} when 0 {Ihr Warenkorb ist leer} when one {Ein Artikel im Warenkorb} when * {{$items} Artikel im Warenkorb}"
}}
@main def demo(): Unit =
given catalogues: Catalogues = Catalogues(Seq(Shop))
for language <- Seq(English, Dutch, Language("de"), Language("nl-BE"), French) do
println(s"${language.tag}: ${Shop.welcome(name = "Ada").localize(language)} / ${Shop.basket(items = 2).localize(language)}")
println(Shop.basket(items = 0).localize(Dutch))
println(catalogues.problems)
The registry reads a family's file for a language the first time that language is needed; there is nothing to configure. The output:
en: Welcome back, Ada / 2 items in your basket
nl: Welkom terug, Ada / 2 artikels in je mandje
de: Willkommen zurück, Ada / 2 Artikel im Warenkorb
nl-BE: Welkom terug, Ada / 2 artikels in je mandje
fr: Welcome back, Ada / 2 items in your basket
Je mandje is leeg
List()
Belgian Dutch has no catalogue of its own and falls back to Dutch; French has none at all and falls back to the source language. A template in a file that does not fit its message would be skipped and listed in catalogues.problems, here empty.
Languages and fallback, message families and their arguments, catalogues, MessageFormat 2.0, formatting, loading in the browser, testing, the export tool, JSON, the ready-made messages and the limits on untrusted input.
From taihei-i18n 4.x, step by step, with the bridge that lets 4.x messages and the new ones live side by side. Also from edo-i18n and genius-i18n.
The code, the issues, the release notes and the security advisories, on GitHub. The 4.x line, taihei-i18n, receives security fixes for 12 months after the release of 5.0.0.