Lokalized facilitates natural-sounding software translations on the JVM.
It is both a file format...
{
"I read {{bookCount}} books." : {
"translation" : "I read {{bookCount}} {{books}}.",
"placeholders" : {
"books" : {
"value" : "bookCount",
"translations" : {
"CARDINALITY_ONE" : "book",
"CARDINALITY_OTHER" : "books"
}
}
},
"alternatives" : [
{
"bookCount == 0" : "I didn't read any books."
}
]
}
}...and a library that operates on it.
String message = strings.get("I read {{bookCount}} books.", Map.of("bookCount", 0));
assertEquals("I didn't read any books.", message);Lokalized has proudly powered production systems since 2017.
Note: this README provides a high-level overview of Lokalized.
For details, please refer to the official documentation at https://www.lokalized.com.
- Keep language rules out of application code: locale-specific grammar and wording live with the translations instead of being scattered through conditionals
- Give translators expressive control: placeholders, language forms, and ordered alternatives can rewrite a fragment or an entire message when natural copy requires it
- Model more than simple plurals: cardinality, ordinality, ranges, gender, grammatical case, definiteness, classifiers, formality, clusivity, animacy, and phonetics are first-class concepts
- Solve agreement problems many localization formats do not model directly: a small but powerful expression language gives translators the freedom to author the natural, idiomatic phrasing each situation requires; see how Lokalized compares
- Match locales predictably:
LocaleMatcherhandles BCP 47 tags, CLDR parent locales, likely scripts, weightedAccept-Languagepreferences, and explicit tiebreakers deterministically; see the matching order - Fail safely: bounded loading and evaluation, explicit fallback policies, and structured diagnostics make malformed or incomplete translations observable; see fallback and failure handling
- Stay lightweight: immutable, thread-safe design. Lokalized requires no runtime dependencies
- Support for date/time, number, percentage, and currency formatting/parsing (JDK provides these)
- Support for collation (JDK provides this)
- Support for Java 8 and below; Lokalized is for Java 9+ only
Similarly-flavored commercially-friendly OSS libraries are available.
- Pyranid - a modern JDBC interface that embraces SQL instead of hiding it behind an ORM
- Soklet - a DI-friendly HTTP/1.1 server with support for virtual threads, Server-Sent Events, and Model Context Protocol
<dependency>
<groupId>com.lokalized</groupId>
<artifactId>lokalized</artifactId>
<version>3.0.0</version>
</dependency>If you don't use Maven or Gradle, you can drop lokalized-3.0.0.jar directly into your project. No other dependencies are required.
We'll start with hands-on examples to illustrate key features.
Filenames must conform to the IETF BCP 47 language tag format, optionally suffixed with .json.
Here is a Brazilian Portuguese (pt-BR) localized strings file which includes a single localization. The English source text remains the lookup key; the file supplies the Brazilian Portuguese rendering:
{
"I read {{bookCount}} books." : {
"translation" : "Li {{bookCount}} {{books}}.",
"placeholders" : {
"books" : {
"value" : "bookCount",
"translations" : {
"CARDINALITY_ONE" : "livro",
"CARDINALITY_OTHER" : "livros"
}
}
},
"alternatives" : [
{
"bookCount == 0" : "Não li nenhum livro."
}
]
}
}final Locale FALLBACK_LOCALE = Locale.forLanguageTag("pt-BR");
// Start the builder with the locale to use when no loaded locale matches
Strings strings = Strings.withFallbackLocale(FALLBACK_LOCALE)
// Load localized strings files from the application directory
.localizedStringSupplier(() -> LocalizedStringLoader.loadFromFilesystem(Paths.get("my-directory")))
// Match the current web request's locale to a loaded file
.localeSupplier((matcher) -> {
Locale locale = MyWebContext.getHttpServletRequest().getLocale();
return matcher.bestMatchFor(locale);
})
// Validate the configuration and create an immutable, thread-safe instance
.build();By default, failed lookups return the key with supplied placeholders interpolated into it. To throw instead:
// Fail-fast
Strings strings = Strings.withFallbackLocale(FALLBACK_LOCALE)
.localizedStringSupplier(() -> LocalizedStringLoader.loadFromFilesystem(Paths.get("my-directory")))
.localeSupplier((matcher) -> matcher.bestMatchFor(FALLBACK_LOCALE))
.translationFailureHandler(TranslationFailureHandler.throwException())
.build();To keep the default fail-soft result while observing structured failures, attach a consumer to returnKey(...):
// Custom telemetry for failures
Strings strings = Strings.withFallbackLocale(FALLBACK_LOCALE)
.localizedStringSupplier(() -> LocalizedStringLoader.loadFromFilesystem(Paths.get("my-directory")))
.localeSupplier((matcher) -> matcher.bestMatchFor(FALLBACK_LOCALE))
.translationFailureHandler(TranslationFailureHandler.returnKey(failure ->
exampleMetrics.increment("lokalized.translation." + failure.getReason())))
.build();Lokalized Strings instances are immutable and safe to share. If your application needs to reload localized strings files, rebuild a new instance and atomically swap the shared AtomicReference:
// Threadsafe reloading in your application via atomic swaps
final class LocalizedStrings {
private static final Locale FALLBACK_LOCALE = Locale.forLanguageTag("pt-BR");
private final AtomicReference<Strings> strings = new AtomicReference<>(load());
public String get(String key, Map<String, Object> placeholders) {
return strings.get().get(key, placeholders);
}
public void reload() {
strings.set(load());
}
private static Strings load() {
return Strings.withFallbackLocale(FALLBACK_LOCALE)
.localizedStringSupplier(() -> LocalizedStringLoader.loadFromFilesystem(Paths.get("my-directory")))
.localeSupplier((matcher) -> matcher.bestMatchFor(MyWebContext.getHttpServletRequest().getLocale()))
.build();
}
}// Lokalized knows how to map numbers to plural cardinalities per locale
// That is, it understands that 3 means CARDINALITY_OTHER ("livros") in Brazilian Portuguese
String message = strings.get("I read {{bookCount}} books.", Map.of("bookCount", 3));
assertEquals("Li 3 livros.", message);
// 1 means CARDINALITY_ONE ("livro") in Brazilian Portuguese
message = strings.get("I read {{bookCount}} books.", Map.of("bookCount", 1));
assertEquals("Li 1 livro.", message);
// A special alternative rule is applied when bookCount == 0
message = strings.get("I read {{bookCount}} books.", Map.of("bookCount", 0));
assertEquals("Não li nenhum livro.", message);Lokalized selects translations and interpolates placeholder values, but it does not format dates, times, numbers, percentages, or currency values. Use JDK formatters such as NumberFormat and DateTimeFormatter for display values before passing them to Lokalized:
// Let the JDK do the formatting lift
Locale locale = Locale.forLanguageTag("fr-FR");
NumberFormat currencyFormat = NumberFormat.getCurrencyInstance(locale);
DateTimeFormatter dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale);
String message = strings.get("Your balance is {{balance}} and is due on {{dueDate}}.", Map.of(
"balance", currencyFormat.format(new BigDecimal("1234.56")),
"dueDate", dateFormat.format(LocalDate.of(2026, 7, 7))
));When a value affects translation selection and also needs locale-aware display formatting, pass separate placeholders: a raw value for Lokalized's language-form rules and a formatted value for interpolation.
{
"You have {{formattedCount}} items." : {
"translation" : "You have {{formattedCount}} {{items}}.",
"placeholders" : {
"items" : {
"value" : "count",
"translations" : {
"CARDINALITY_ONE" : "item",
"CARDINALITY_OTHER" : "items"
}
}
}
}
}// Provide both "raw" and "formatted" values to support placeholder logic
int count = 12_345;
Locale locale = Locale.forLanguageTag("en-US");
String message = strings.get("You have {{formattedCount}} items.", Map.of(
"count", count,
"formattedCount", NumberFormat.getIntegerInstance(locale).format(count)
));Suppose you have two localized strings files for English - American (en-US) and British (en-GB).
A caller requests Canadian English (en-CA). Neither an exact match nor a CLDR parent locale is present in the loaded files, and both files are compatible en-Latn candidates. The configured order therefore decides which translation wins.
Fails fast during construction: Lokalized detects multiple loaded locales with the same primary language. If an application constructs Strings during startup, build() throws IllegalArgumentException unless each ambiguous language has a tiebreaker list containing every loaded locale exactly once.
Strings strings = Strings.withFallbackLocale(FALLBACK_LOCALE)
.localizedStringSupplier(() -> LocalizedStringLoader.loadFromFilesystem(Paths.get("my-directory")))
.localeSupplier((matcher) -> {
Locale locale = MyWebContext.getHttpServletRequest().getLocale();
return matcher.bestMatchFor(locale);
})
// Declare a complete order for each language with multiple loaded locales
// A request such as en-CA selects American English when no earlier
// exact, canonical, or CLDR-parent match resolves it
.tiebreakerLocalesByLanguageCode(Map.of(
"en", List.of(Locale.forLanguageTag("en-US"), Locale.forLanguageTag("en-GB"))
))
.build();tiebreakerLocalesByLanguageCode(...)
map keys are primary BCP 47 language subtags. Each list must contain every loaded locale for that language exactly once, and
canonical aliases such as he and iw may not be configured as separate keys.
Here's a common scenario: a user visits your webapp, and their browser automatically populates the Accept-Language HTTP request header with
an RFC 9110 weighted preference list like en-GB;q=1.0,en;q=0.75,fr-FR;q=0.25.
That one says: "I prefer British English, then other forms of English, then French (from France) - in that order."
Lokalized offers "best match" functionality which evaluates the combination of your available localized strings files and
the raw header value to pick the most appropriate localization that your application supports for that user.
bestMatchForAcceptLanguage(...)
is bounded and fail-soft for request handling.
Strings strings = Strings.withFallbackLocale(FALLBACK_LOCALE)
.localizedStringSupplier(() -> LocalizedStringLoader.loadFromFilesystem(Paths.get("my-directory")))
// The helper combines repeated Accept-Language field lines in received order
.localeSupplier((matcher) -> matcher.bestMatchForAcceptLanguage(
MyWebContext.getCombinedAcceptLanguageHeader()))
.build();bestMatchFor(Locale) and bestMatchFor(List<LanguageRange>) match requested locale preferences against the locales loaded from your localized strings files. Matching is deterministic and follows these broad rules:
- An exact language tag from a localized strings file wins before a CLDR-canonical-equivalent tag; deprecated and legacy aliases are then considered
- CLDR parent locales are considered before looser language-only matches. For example,
en-AUcan prefer a configureden-001file beforeen - Matching is script-aware when CLDR likely-subtag data can infer a script. For example,
zh-TWcan matchzh-Hant, andsr-Latnis distinct fromsr-Cyrl - The Norwegian macrolanguage tag
noand Norwegian Bokmål tagnbbridge to each other as a compatibility fallback; exact files still win first - If multiple supported files share the same language and no exact, parent, or script-aware match resolves the request,
tiebreakerLocalesByLanguageCode(...)controls which locale wins - Language-range quality weights are honored, and a
q=0range excludes that locale and its matching descendants when another acceptable loaded locale remains Locale.ROOT,und, wildcard-only ranges, empty preference lists, and unmatched requests resolve to the configured fallback locale
The examples below assume that no exact file for the requested language tag is loaded unless the row says otherwise. An exact loaded file always wins.
| Request | CLDR interpretation | Preferred compatible file |
|---|---|---|
zh-TW |
Traditional Chinese | zh-Hant |
zh-HK |
Traditional Chinese | zh-Hant |
zh-CN |
Simplified Chinese | zh-Hans |
zh |
Simplified Chinese by default | zh-Hans |
sr |
Cyrillic Serbian by default | sr-Cyrl |
sr-Latn |
Latin Serbian | sr-Latn |
sh |
Legacy tag associated with Latin Serbian | sr-Latn |
| Request | Loaded files | Result |
|---|---|---|
en-AU |
en-001, en |
en-001 |
en-AU |
en |
en |
en-CA |
en-US, en-GB |
First configured English tiebreaker |
fr-BE |
fr-FR, fr-CA |
First configured French tiebreaker |
| Request | Candidate order |
|---|---|
no-NO |
no-NO → no → nb-NO → nb |
nb-NO |
nb-NO → nb → no → no-NO |
Norwegian Nynorsk (nn) is independent and does not participate in this bridge.
LocaleMatcher accepts at most
32 parsed language ranges
per call to bound matching work. This count applies to the list returned by
LanguageRange.parse(...),
which may add IANA-equivalent ranges beyond those written in the header.
TranslationOptions enforces the same limit when
the options are constructed, before a lookup begins. The raw-header convenience method above bounds input at 4,096
UTF-16 code units before parsing and uses the configured fallback for missing, blank, malformed, or over-limit values;
it never truncates preferences. bestMatchFor(...) always returns a locale and uses the configured fallback when
nothing is acceptable.
Most applications load localized strings files from the filesystem during development and from the classpath in packaged deployments using LocalizedStringLoader.
Strings filesystemStrings = Strings.withFallbackLocale(Locale.forLanguageTag("en"))
.localizedStringSupplier(() -> LocalizedStringLoader.loadFromFilesystem(Paths.get("strings")))
.localeSupplier((matcher) -> matcher.bestMatchFor(Locale.US))
.build();
Strings classpathStrings = Strings.withFallbackLocale(Locale.forLanguageTag("en"))
.localizedStringSupplier(() -> LocalizedStringLoader.loadFromClasspath("strings"))
.localeSupplier((matcher) -> matcher.bestMatchFor(Locale.US))
.build();Classpath package names use slash-separated resource paths such as com/example/myapp/strings. Prefer an
application-specific, namespaced path over a generic top-level package like strings so unrelated dependencies cannot
publish resources into the same package. loadFromClasspath(String) first uses the current thread context classloader when one is available, then falls back to Lokalized's own classloader. Use loadFromClasspath(ClassLoader, String) for containers, plugin systems, test harnesses, and other environments where the desired resources are visible through a specific classloader.
Filesystem and classpath loading both scan only the specified directory or package; child directories and child packages are not scanned recursively.
Classpath package names must be nonempty slash-relative paths and may not contain empty interior, . or .. segments.
One or more trailing slashes are ignored; leading slashes and traversal remain invalid.
The multi-release JAR implementation namespace under META-INF/versions is reserved from package discovery; use
loadFromClasspathResources(...)
when an application intentionally needs an exact resource path.
The valid BCP 47 tag und represents Java's Locale.ROOT, so a root localized strings file is named und.json; tags such as
und-Latn.json are also supported.
Loading is bounded per resource and per load by LocalizedStringLoadingOptions.
The default limit is 8 MiB for a Path or InputStream, 8,388,608 UTF-16 code units for a Reader, and 64 levels of
JSON object/array nesting. A filesystem directory, discovered classpath package, or explicit classpath-resource mapping
is additionally limited to 32 MiB total input and 256 localized strings files. All loading operations, including single-resource
parse(...) methods, accept at most 100,000 translation nodes and 1,000 warnings by default. The node count includes
top-level messages, whole-message alternatives, every placeholder definition, and every expression-selected fragment
alternative, so conditional structures cannot bypass the work budget. Overloads
accepting loading options may lower or raise these defaults; the loader's hard maximum nesting depth is 128. The total
input-byte limit also applies to single-resource Path and InputStream parsing, while it cannot apply to a Reader
because the original byte representation is unavailable. A single-resource parse consumes one localized strings file
from its file-count budget. Discovery-based filesystem and classpath loads examine at most 100,000 entries by default;
this limit is configurable up to 1,000,000. Explicit locale-to-resource maps and single-resource parsing enumerate no
candidates and do not consume the discovery budget. Input streams are decoded as strict UTF-8, and blank or BOM-only
localized strings files are rejected - use {} for an intentionally empty localized strings file.
Classpath loading normally uses the classloader's package-resource discovery and does not sweep every classpath root.
Some JAR creation tools omit directory entries, which makes their packages invisible to ordinary discovery. Enable
exhaustiveClasspathSearch(true)
only when you need to support such a JAR; this inspects every filesystem and JAR root
visible to the classloader, including filesystem JARs referenced through manifest Class-Path entries. Localized strings
files in multi-release JARs use the entry selected for the running Java version. A .json resource in a classpath package whose filename is not a valid language tag is ignored
with a warning so an unrelated dependency cannot abort application startup. Filesystem loading remains strict and rejects
the same filename, which catches mistakes in a localized strings directory owned by the application.
LocalizedStringLoadingOptions limits = LocalizedStringLoadingOptions.builder()
.maximumInputBytes(4 * 1024 * 1024)
.maximumReaderCharacters(4 * 1024 * 1024)
.maximumTotalInputBytes(16L * 1024L * 1024L)
.maximumLocalizedStringsFiles(100)
.maximumTranslationNodes(25_000)
.maximumWarnings(500)
.maximumJsonNestingDepth(32)
.exhaustiveClasspathSearch(true) // Only for JARs that omit package directory entries
.build();
Map<Locale, Set<LocalizedString>> localizedStringsByLocale =
LocalizedStringLoader.loadFromClasspath("strings", limits);Some container and plugin classloaders can open known resources but cannot enumerate a package or expose a standard
file:/jar: package URL. Use
loadFromClasspathResources(...)
to map locales to exact resource paths in those environments; this path uses
ClassLoader.getResourceAsStream(...) and performs no package discovery:
Map<Locale, Set<LocalizedString>> localizedStringsByLocale = LocalizedStringLoader.loadFromClasspathResources(
pluginClassLoader,
Map.of(
Locale.ROOT, "myapp/strings/und.json",
Locale.ENGLISH, "myapp/strings/en.json",
Locale.FRENCH, "myapp/strings/fr.json"
),
limits
);The localeSupplier(...)
configured on Strings.Builder is convenient for web
requests and other request-scoped contexts. For async jobs, batch work, tests, administrative tooling, or alternate
output sinks, use TranslationOptions to override
lookup behavior for a single invocation:
String message = strings.get(
"I read {{bookCount}} books.",
Map.of("bookCount", 1),
TranslationOptions.forLocale(Locale.forLanguageTag("fr-CA"))
);Per-invocation options can supply a locale, language ranges, bidi isolation behavior,
TranslationFallbackPolicy, or
TranslationFailureHandler. Locale and
language-range options bypass the configured
localeSupplier(...)
or localeMatchSupplier(...)
for that call. Lokalized still applies the same matching, tiebreakers, and fallback behavior
using the preference you supplied.
Localized strings compilation and translation evaluation use immutable TranslationRuntimeLimits.
The defaults cap numbers and numeric literals at 1,024 digits of precision and absolute scale, explicitly visible
decimal places at 1,024, compact exponents at 64, expressions at 2,048 characters / 256 tokens / 32 nested groups,
generated placeholders at 32 levels, one interpolated result or phonetic input at 262,144 UTF-16 code units, and
cumulative generated-fragment expansion at 1,048,576 UTF-16 code units per locale fallback attempt. Locale fallback starts a
fresh generated-expansion budget for each candidate. Applications may lower or raise the defaults with
TranslationRuntimeLimits.builder()
and TranslationRuntimeLimits.Builder.
Hard ceilings remain 4,096 for numeric precision, absolute scale, visible decimal places, and compact exponents;
4,096 characters / 512 tokens / 64 nested groups for expressions; 64 levels for generated placeholders; 1,048,576
UTF-16 code units for one interpolated result or phonetic input; and 8,388,608 UTF-16 code units for cumulative
generated expansion.
TranslationRuntimeLimits runtimeLimits = TranslationRuntimeLimits.builder()
.maximumExpressionCharacters(1_024)
.maximumExpressionTokens(128)
.maximumInterpolatedOutputCharacters(128 * 1_024)
.maximumGeneratedExpansionCharacters(512 * 1_024)
.build();
Strings strings = Strings.withFallbackLocale(Locale.ENGLISH)
.localizedStringSupplier(() -> LocalizedStringLoader.loadFromClasspath("com/example/myapp/strings"))
.localeSupplier(matcher -> Locale.ENGLISH)
.runtimeLimits(runtimeLimits)
.build();Apply the limits to a provider with
Strings.Builder.runtimeLimits(...).
It can also be supplied directly through
PluralOperands.Builder.runtimeLimits(...).
The loader validates expressions and numeric literals against hard ceilings because it does not yet know which runtime
policy an application will select. Strings construction then compiles the loaded localized strings and enforces its
configured limits, so a localized strings file may load successfully but be rejected by Strings under the defaults.
This preserves an explicit opt-up
path without allowing work above the hard ceilings. Violations caused by lookup values or generated expansion are
resolution failures.
Lokalized's strength is handling phrases that must be rewritten in different ways according to language rules. Suppose we introduce gender alongside plural forms. In English, a noun's gender usually does not alter other components of a phrase. But in Spanish it does.
This English statement has singular and plural variants for masculine, feminine, and common gender:
He was one of the X best baseball players.She was one of the X best baseball players.This person was one of the X best baseball players.He was the best baseball player.She was the best baseball player.This person was the best baseball player.
For the masculine and feminine Spanish cases, notice how several words must change to match gender - uno becomes
una, jugadores becomes jugadoras, and so on. For common gender, the translation instead uses persona and a
relative clause so agreement does not depend on the referent's gender. Persona is grammatically feminine regardless
of whom it denotes, so las personas does not imply an all-female group.
Fue uno de los X mejores jugadores de béisbol.Fue una de las X mejores jugadoras de béisbol.Esta persona estaba entre las X personas que mejor jugaban al béisbol.Él era el mejor jugador de béisbol.Ella era la mejor jugadora de béisbol.Esta persona era quien mejor jugaba al béisbol.
English is a little simpler than Spanish here because gender affects only the generated subject fragment.
{
"{{heOrShe}} was one of the {{groupSize}} best baseball players." : {
"translation" : "{{heOrShe}} was one of the {{groupSize}} best baseball players.",
"placeholders" : {
"heOrShe" : {
"value" : "heOrShe",
"translations" : {
"GENDER_MASCULINE" : "He",
"GENDER_FEMININE" : "She",
"GENDER_COMMON" : "This person"
}
}
},
"alternatives" : [
{
"groupSize <= 1" : "{{heOrShe}} was the best baseball player."
}
]
}
}The singular alternative inherits the root heOrShe definition. The selected branch can therefore reuse the same
gender fragment instead of duplicating one whole-message alternative per gender.
Note that we define our own placeholders in translation and drive them off of the heOrShe value to support gender-based word changes.
{
"{{heOrShe}} was one of the {{groupSize}} best baseball players." : {
"translation" : "Fue {{uno}} de {{los}} {{groupSize}} mejores {{jugadores}} de béisbol.",
"placeholders" : {
"uno" : {
"value" : "heOrShe",
"translations" : {
"GENDER_MASCULINE" : "uno",
"GENDER_FEMININE" : "una"
}
},
"los" : {
"value" : "heOrShe",
"translations" : {
"GENDER_MASCULINE" : "los",
"GENDER_FEMININE" : "las"
}
},
"jugadores" : {
"value" : "heOrShe",
"translations" : {
"GENDER_MASCULINE" : "jugadores",
"GENDER_FEMININE" : "jugadoras"
}
}
},
"alternatives" : [
{
"heOrShe == GENDER_COMMON && groupSize <= 1" : "Esta persona era quien mejor jugaba al béisbol."
},
{
"heOrShe == GENDER_COMMON" : "Esta persona estaba entre las {{groupSize}} personas que mejor jugaban al béisbol."
},
{
"heOrShe == GENDER_MASCULINE && groupSize <= 1" : "Él era el mejor jugador de béisbol."
},
{
"heOrShe == GENDER_FEMININE && groupSize <= 1" : "Ella era la mejor jugadora de béisbol."
}
]
}
}Notice that we keep the gender and plural logic out of our code entirely and leave rule processing to the translation configuration.
// "Normal" translation
String message = strings.get("{{heOrShe}} was one of the {{groupSize}} best baseball players.",
Map.of(
"heOrShe", Gender.MASCULINE,
"groupSize", 10
));
assertEquals("He was one of the 10 best baseball players.", message);
// Alternative expression triggered
message = strings.get("{{heOrShe}} was one of the {{groupSize}} best baseball players.",
Map.of(
"heOrShe", Gender.MASCULINE,
"groupSize", 1
));
assertEquals("He was the best baseball player.", message);
// ...now, here's what a Mexican Spanish (`es-MX`) user might see:
message = strings.get("{{heOrShe}} was one of the {{groupSize}} best baseball players.",
Map.of(
"heOrShe", Gender.FEMININE,
"groupSize", 3
));
// Note that the correct feminine forms were applied
assertEquals("Fue una de las 3 mejores jugadoras de béisbol.", message);
// Spanish - common-gender wording uses persona instead of gendered jugador/jugadora forms
message = strings.get("{{heOrShe}} was one of the {{groupSize}} best baseball players.",
Map.of(
"heOrShe", Gender.COMMON,
"groupSize", 3
));
assertEquals("Esta persona estaba entre las 3 personas que mejor jugaban al béisbol.", message);
message = strings.get("{{heOrShe}} was one of the {{groupSize}} best baseball players.",
Map.of(
"heOrShe", Gender.COMMON,
"groupSize", 1
));
assertEquals("Esta persona era quien mejor jugaba al béisbol.", message);When expressing a range of values (1-3 meters, 2.5-3.5 hours), the cardinality of the range is determined by applying per-language rules to its start and end cardinalities.
Every explicitly listed English CLDR range pair selects CARDINALITY_OTHER, but unlisted pairs use Lokalized's end-category fallback. For example, CARDINALITY_ONE -> CARDINALITY_ONE is unlisted and therefore evaluates to CARDINALITY_ONE. Many other languages have additional range-specific forms.
French ranges can be either CARDINALITY_ONE or CARDINALITY_OTHER.
{
"The meeting will be {{minHours}}-{{maxHours}} hours long." : {
"translation" : "La réunion aura une durée de {{minHours}} à {{maxHours}} {{heures}}.",
"placeholders" : {
"heures" : {
"range" : {
"start" : "minHours",
"end" : "maxHours"
},
"translations" : {
"CARDINALITY_ONE" : "heure",
"CARDINALITY_OTHER" : "heures"
}
}
}
}
}English ranges with different endpoints use the explicit CLDR mappings and evaluate to CARDINALITY_OTHER. Equal singular endpoints (CARDINALITY_ONE -> CARDINALITY_ONE) use the end-category fallback and evaluate to CARDINALITY_ONE, so a translation that must handle that case should provide both forms.
{
"The meeting will be {{minHours}}-{{maxHours}} hours long." : {
"translation" : "The meeting will be {{minHours}}-{{maxHours}} {{hours}} long.",
"placeholders" : {
"hours" : {
"range" : {
"start" : "minHours",
"end" : "maxHours"
},
"translations" : {
"CARDINALITY_ONE" : "hour",
"CARDINALITY_OTHER" : "hours"
}
}
}
}
}// French CARDINALITY_OTHER case
String message = strings.get("The meeting will be {{minHours}}-{{maxHours}} hours long.",
Map.of(
"minHours", 1,
"maxHours", 3
));
assertEquals("La réunion aura une durée de 1 à 3 heures.", message);
// French CARDINALITY_ONE case
message = strings.get("The meeting will be {{minHours}}-{{maxHours}} hours long.",
Map.of(
"minHours", 0,
"maxHours", 1
));
assertEquals("La réunion aura une durée de 0 à 1 heure.", message);Many languages have special forms called ordinals to express a "ranking" in a sequence of numbers. For example, in English we might say
Take the 1st left after the intersectionShe is my 2nd cousinI finished the race in 3rd place
Let's look at an example related to birthdays.
CLDR defines four ordinal categories for English.
{
"{{hisOrHer}} {{year}}th birthday party is next week." : {
"translation" : "{{hisOrHer}} {{year}}{{ordinal}} birthday party is next week.",
"placeholders" : {
"hisOrHer" : {
"value" : "hisOrHer",
"translations" : {
"GENDER_MASCULINE" : "His",
"GENDER_FEMININE" : "Her"
}
},
"ordinal" : {
"value" : "year",
"translations" : {
"ORDINALITY_ONE" : "st",
"ORDINALITY_TWO" : "nd",
"ORDINALITY_FEW" : "rd",
"ORDINALITY_OTHER" : "th"
}
}
}
}
}CLDR assigns Spanish only ORDINALITY_OTHER, so ordinal category selection does not vary by number. Spanish still has ordinal expressions; this example uses application-specific birthday wording instead of an ordinal-suffix map.
{
"{{hisOrHer}} {{year}}th birthday party is next week." : {
"translation" : "Su fiesta de cumpleaños número {{year}} es la próxima semana.",
"alternatives" : [
{
"year == 1" : "Su primera fiesta de cumpleaños es la próxima semana."
},
{
"hisOrHer == GENDER_FEMININE && year == 15" : "Su quinceañera es la próxima semana."
}
]
}
}// The ORDINALITY_OTHER rule is applied for 18 in English
String message = strings.get("{{hisOrHer}} {{year}}th birthday party is next week.",
Map.of(
"hisOrHer", Gender.MASCULINE,
"year", 18
));
assertEquals("His 18th birthday party is next week.", message);
// The ORDINALITY_ONE rule is applied to any of the "one" numbers (1, 21, 31, ...) in English
message = strings.get("{{hisOrHer}} {{year}}th birthday party is next week.",
Map.of(
"hisOrHer", Gender.FEMININE,
"year", 21
));
assertEquals("Her 21st birthday party is next week.", message);
// Spanish - normal case
message = strings.get("{{hisOrHer}} {{year}}th birthday party is next week.",
Map.of(
"hisOrHer", Gender.MASCULINE,
"year", 18
));
assertEquals("Su fiesta de cumpleaños número 18 es la próxima semana.", message);
// Spanish - special case for first birthday
message = strings.get("{{hisOrHer}} {{year}}th birthday party is next week.",
Map.of(
"year", 1
));
assertEquals("Su primera fiesta de cumpleaños es la próxima semana.", message);
// Spanish - special case for a girl's 15th birthday
message = strings.get("{{hisOrHer}} {{year}}th birthday party is next week.",
Map.of(
"hisOrHer", Gender.FEMININE,
"year", 15
));
assertEquals("Su quinceañera es la próxima semana.", message);Gender rules vary across languages, but the general meaning is the same.
Lokalized supports these values:
Some languages (e.g. Swedish, Danish, Dutch) collapse masculine and feminine into a common gender. Use
GENDER_COMMON for that class (for example, Swedish en words) and GENDER_NEUTER for neuter (ett words).
Lokalized provides a Gender type which enumerates supported genders.
Grammatical case rules determine how a noun or pronoun changes according to its syntactic role.
Lokalized supports these values:
CASE_NOMINATIVECASE_ACCUSATIVECASE_GENITIVECASE_DATIVECASE_INSTRUMENTALCASE_LOCATIVECASE_PREPOSITIONALCASE_VOCATIVECASE_ABLATIVE
Lokalized provides a GrammaticalCase type which enumerates supported case values. The enum is intentionally high-coverage rather than exhaustive; if a language distinguishes more cases, map them to the closest supported value in your application code.
In Russian, a recipient often takes dative case:
{
"Send a message to the recipient." : {
"translation" : "Отправить сообщение {{recipientForm}}.",
"placeholders" : {
"recipientForm" : {
"value" : "grammaticalCase",
"translations" : {
"CASE_NOMINATIVE" : "Иван",
"CASE_DATIVE" : "Ивану",
"CASE_ACCUSATIVE" : "Ивана"
}
}
}
}
}Now select the grammatical role at runtime:
Strings strings = Strings.withFallbackLocale(Locale.forLanguageTag("ru"))
.localizedStringSupplier(() -> LocalizedStringLoader.loadFromClasspath("strings"))
.localeSupplier(matcher -> Locale.forLanguageTag("ru"))
.build();
assertEquals("Отправить сообщение Ивану.", strings.get("Send a message to the recipient.", Map.of(
"grammaticalCase", GrammaticalCase.DATIVE
)));This example is intentionally partial: if application code supplies a grammatical case that is not listed here, the lookup is treated as a resolution failure and your configured TranslationFailureHandler decides what happens. The default returns the key; use TranslationFailureHandler.throwException() to throw.
Definiteness rules distinguish whether a noun phrase is definite, indefinite, or in construct/bound state.
Lokalized supports these values:
Lokalized provides a Definiteness type which enumerates supported definiteness values.
Arabic and Hebrew frequently change noun phrases based on definiteness:
{
"Open the document." : {
"translation" : "افتح {{documentForm}}.",
"placeholders" : {
"documentForm" : {
"value" : "definiteness",
"translations" : {
"DEFINITENESS_DEFINITE" : "الكتاب",
"DEFINITENESS_INDEFINITE" : "كتابًا",
"DEFINITENESS_CONSTRUCT" : "كتاب"
}
}
}
}
}Then choose the desired form at runtime:
Strings strings = Strings.withFallbackLocale(Locale.forLanguageTag("ar"))
.localizedStringSupplier(() -> LocalizedStringLoader.loadFromClasspath("strings"))
.localeSupplier(matcher -> Locale.forLanguageTag("ar"))
.build();
assertEquals("افتح الكتاب.", strings.get("Open the document.", Map.of(
"definiteness", Definiteness.DEFINITE
)));Classifier rules select the measure word or counter associated with a noun.
Lokalized supports these values:
CLASSIFIER_GENERALCLASSIFIER_PERSONCLASSIFIER_ANIMALCLASSIFIER_LONG_THINCLASSIFIER_FLATCLASSIFIER_BOUNDCLASSIFIER_MACHINECLASSIFIER_VEHICLE
Lokalized provides a Classifier type which enumerates supported classifier categories. This enum is intentionally generic and non-exhaustive: it captures common semantic buckets across classifier languages, but applications with language-specific inventories may still want separate keys or alternative expressions in some cases.
In Japanese, the counter for books differs from the general-purpose counter:
{
"I bought {{count}} items." : {
"translation" : "{{count}}{{counter}}買いました。",
"placeholders" : {
"counter" : {
"value" : "classifier",
"translations" : {
"CLASSIFIER_GENERAL" : "つ",
"CLASSIFIER_BOUND" : "冊",
"CLASSIFIER_MACHINE" : "台"
}
}
}
}
}Then choose the classifier category in calling code:
Strings strings = Strings.withFallbackLocale(Locale.forLanguageTag("ja"))
.localizedStringSupplier(() -> LocalizedStringLoader.loadFromClasspath("strings"))
.localeSupplier(matcher -> Locale.forLanguageTag("ja"))
.build();
assertEquals("3冊買いました。", strings.get("I bought {{count}} items.", Map.of(
"count", 3,
"classifier", Classifier.BOUND
)));This example is intentionally partial: if application code supplies a classifier that is not listed here, the lookup is treated as a resolution failure and your configured TranslationFailureHandler decides what happens. The default returns the key; use TranslationFailureHandler.throwException() to throw.
Formality rules determine whether a phrase is rendered in a casual, informal, formal, humble, or honorific register.
Lokalized supports these values:
Lokalized provides a Formality type which enumerates supported formality values.
Let's model a greeting with different levels of formality:
{
"Hello, {{name}}." : {
"translation" : "{{greeting}}, {{name}}.",
"placeholders" : {
"greeting" : {
"value" : "formality",
"translations" : {
"FORMALITY_CASUAL" : "Hey",
"FORMALITY_INFORMAL" : "Hi",
"FORMALITY_FORMAL" : "Hello",
"FORMALITY_HUMBLE" : "I humbly greet you",
"FORMALITY_HONORIFIC" : "Greetings"
}
}
}
}
}Now select the register at runtime:
Strings strings = Strings.withFallbackLocale(Locale.forLanguageTag("en"))
.localizedStringSupplier(() -> LocalizedStringLoader.loadFromClasspath("strings"))
.localeSupplier(matcher -> Locale.forLanguageTag("en"))
.build();
assertEquals("Greetings, Dr. Smith.", strings.get("Hello, {{name}}.", Map.of(
"formality", Formality.HONORIFIC,
"name", "Dr. Smith"
)));
assertEquals("Hey, Sam.", strings.get("Hello, {{name}}.", Map.of(
"formality", Formality.CASUAL,
"name", "Sam"
)));
assertEquals("I humbly greet you, Professor Tanaka.", strings.get("Hello, {{name}}.", Map.of(
"formality", Formality.HUMBLE,
"name", "Professor Tanaka"
)));
assertEquals("Hi, Sam.", strings.get("Hello, {{name}}.", Map.of(
"formality", Formality.INFORMAL,
"name", "Sam"
)));Clusivity rules distinguish between inclusive and exclusive first-person plurals.
Lokalized supports these values:
Lokalized provides a Clusivity type which enumerates supported clusivity values.
In Malay, kita includes the addressee while kami excludes them. Let's model We will meet at noon.:
{
"We will meet at noon." : {
"translation" : "{{we}} akan bertemu pada tengah hari.",
"placeholders" : {
"we" : {
"value" : "clusivity",
"translations" : {
"CLUSIVITY_INCLUSIVE" : "Kita",
"CLUSIVITY_EXCLUSIVE" : "Kami"
}
}
}
}
}Now choose inclusive vs exclusive at runtime:
Strings strings = Strings.withFallbackLocale(Locale.forLanguageTag("ms"))
.localizedStringSupplier(() -> LocalizedStringLoader.loadFromClasspath("strings"))
.localeSupplier(matcher -> Locale.forLanguageTag("ms"))
.build();
assertEquals("Kita akan bertemu pada tengah hari.", strings.get("We will meet at noon.", Map.of(
"clusivity", Clusivity.INCLUSIVE
)));
assertEquals("Kami akan bertemu pada tengah hari.", strings.get("We will meet at noon.", Map.of(
"clusivity", Clusivity.EXCLUSIVE
)));Animacy rules distinguish between animate and inanimate referents.
Lokalized supports these values:
Lokalized provides an Animacy type which enumerates supported animacy values.
In Russian, masculine accusative forms often change based on animacy. Here's a simple example:
{
"I see {{object}}." : {
"translation" : "Я вижу {{object}}.",
"placeholders" : {
"object" : {
"value" : "animacy",
"translations" : {
"ANIMACY_ANIMATE" : "брата",
"ANIMACY_INANIMATE" : "стол"
}
}
}
}
}Then select the animacy value at runtime:
Strings strings = Strings.withFallbackLocale(Locale.forLanguageTag("ru"))
.localizedStringSupplier(() -> LocalizedStringLoader.loadFromClasspath("strings"))
.localeSupplier(matcher -> Locale.forLanguageTag("ru"))
.build();
assertEquals("Я вижу брата.", strings.get("I see {{object}}.", Map.of(
"animacy", Animacy.ANIMATE
)));
assertEquals("Я вижу стол.", strings.get("I see {{object}}.", Map.of(
"animacy", Animacy.INANIMATE
)));For example: 1 book, 2 books, ...
Plural rules vary widely across languages.
Lokalized supports these values according to CLDR 48 rules:
Values do not necessarily map exactly to the named number, e.g. in some languages CARDINALITY_ONE might mean any number ending in 1, not just 1. Most languages only support a few plural forms, some have none at all (represented by CARDINALITY_OTHER in those cases).
CARDINALITY_OTHER: Matches everything (this language has no plural form)
CARDINALITY_ONE: Matches 1 (e.g.1 dollar)CARDINALITY_OTHER: Everything else (e.g.256 dollars)
CARDINALITY_ONE: Matches 1, 21, 31, ... (e.g.1 рубльor51 рубль)CARDINALITY_FEW: Matches 2-4, 22-24, 32-34, ... (e.g.2 рубляor53 рубля)CARDINALITY_MANY: Matches 0, 5-20, 25-30, 45-50, ... (e.g.5 рублейor17 рублей)CARDINALITY_OTHER: Everything else (e.g.0,3 руб,1,5 руб)
Lokalized provides a Cardinality type which encapsulates cardinal functionality.
Cardinality#getSupportedLocaleTags() returns the language tags represented directly in the pinned CLDR cardinality-rule data. Concrete region- or script-qualified languages can also work through rule fallback; use Cardinality#supportedCardinalitiesForLocale(Locale) to probe one.
You may programmatically determine cardinality using Cardinality#forNumber(Number number, Locale locale) and Cardinality#forNumber(Number number, Integer visibleDecimalPlaces, Locale locale) as shown below.
It is important to note that the number of visible decimal places can be important for some languages when performing cardinality evaluation. For example, in English, 1 matches CARDINALITY_ONE but 1.0 matches CARDINALITY_OTHER. Even though the numbers' true values are identical, you would say 1 inch and 1.0 inches and therefore must take visible decimals into account.
// Basic case - a primitive number, no decimals
Cardinality cardinality = Cardinality.forNumber(1, Locale.forLanguageTag("en"));
assertEquals(Cardinality.ONE, cardinality);
// In the absence of an explicit number of visible decimals,
// 1.0 evaluates to Cardinality.ONE since primitive 1 == primitive 1.0
cardinality = Cardinality.forNumber(1.0, Locale.forLanguageTag("en"));
assertEquals(Cardinality.ONE, cardinality);
// With 1 visible decimal specified ("1.0"), we evaluate to Cardinality.OTHER
cardinality = Cardinality.forNumber(1, 1, Locale.forLanguageTag("en"));
assertEquals(Cardinality.OTHER, cardinality);
// Let's try BigDecimal instead of a primitive...
cardinality = Cardinality.forNumber(new BigDecimal("1"), Locale.forLanguageTag("en"));
assertEquals(Cardinality.ONE, cardinality);
// Using BigDecimal obviates the need to specify visible decimals
// since they can be encoded directly in the number
// We evaluate to Cardinality.OTHER, as expected
cardinality = Cardinality.forNumber(new BigDecimal("1.0"), Locale.forLanguageTag("en"));
assertEquals(Cardinality.OTHER, cardinality);For compact-decimal displays, use PluralOperands when CLDR needs operand details that are not fully represented by the Java number itself:
PluralOperands operands = PluralOperands.forNumber(2)
.compactExponent(6)
.build();
cardinality = Cardinality.forOperands(operands, Locale.forLanguageTag("fr"));
assertEquals(Cardinality.MANY, cardinality);Here, 2 is the displayed mantissa and the compact exponent 6 carries the magnitude (for example, a display such
as 2M). Lokalized accepts BigDecimal, BigInteger, the boxed integral and floating-point JDK types, and the JDK
atomic/adder/accumulator numeric types documented by PluralOperands. Unknown Number implementations are rejected;
convert an application-specific number to BigDecimal explicitly so precision is never guessed through
doubleValue() or an arbitrary toString() representation.
visibleDecimalPlaces(...) may add trailing zeroes, but it never rounds a number implicitly. If reducing the
scale would discard a nonzero digit, build() throws ArithmeticException; round the displayed value explicitly
before constructing its operands so plural selection and presentation cannot silently disagree.
To keep plural-operand construction predictably bounded, the defaults accept at most 1,024 significant digits, an
absolute decimal scale of 1,024, 1,024 explicitly visible decimal places, and a compact exponent of 64. Applications
can configure these through TranslationRuntimeLimits,
up to hard ceilings of 4,096 for each value. The hard ceilings are also exposed as boxed constants on
PluralOperands.
Apply custom limits to a Strings instance with
Strings.Builder.runtimeLimits(...)
or to direct plural-operand construction with
PluralOperands.Builder.runtimeLimits(...).
The Cardinality.forNumber(...)
and Ordinality.forNumber(...)
convenience methods use the library defaults; for an opted-up value, build PluralOperands explicitly and pass them to
Cardinality.forOperands(...)
or Ordinality.forOperands(...).
For example: 0-1 hours, 1-2 hours, ...
The plural form of the range is determined by examining the cardinality of its start and end components.
CARDINALITY_ONE-CARDINALITY_OTHER⇒CARDINALITY_OTHER(e.g.1-2 days)CARDINALITY_OTHER-CARDINALITY_ONE⇒CARDINALITY_OTHER(e.g.0-1 days)CARDINALITY_OTHER-CARDINALITY_OTHER⇒CARDINALITY_OTHER(e.g.0-2 days)
CARDINALITY_ONE-CARDINALITY_ONE⇒CARDINALITY_ONE(e.g.0-1 jour)CARDINALITY_ONE-CARDINALITY_OTHER⇒CARDINALITY_OTHER(e.g.0-2 jours)CARDINALITY_OTHER-CARDINALITY_OTHER⇒CARDINALITY_OTHER(e.g.2-100 jours)
CARDINALITY_ZERO-CARDINALITY_ZERO⇒CARDINALITY_OTHER(e.g.0-10 diennaktis)CARDINALITY_ZERO-CARDINALITY_ONE⇒CARDINALITY_ONE(e.g.0-1 diennakts)CARDINALITY_ZERO-CARDINALITY_OTHER⇒CARDINALITY_OTHER(e.g.0-2 diennaktis)CARDINALITY_ONE-CARDINALITY_ZERO⇒CARDINALITY_OTHER(e.g.0,1-10 diennaktis)CARDINALITY_ONE-CARDINALITY_ONE⇒CARDINALITY_ONE(e.g.0,1-1 diennakts)CARDINALITY_ONE-CARDINALITY_OTHER⇒CARDINALITY_OTHER(e.g.0,1-2 diennaktis)CARDINALITY_OTHER-CARDINALITY_ZERO⇒CARDINALITY_OTHER(e.g.0,2-10 diennaktis)CARDINALITY_OTHER-CARDINALITY_ONE⇒CARDINALITY_ONE(e.g.0,2-1 diennakts)CARDINALITY_OTHER-CARDINALITY_OTHER⇒CARDINALITY_OTHER(e.g.0,2-2 diennaktis)
You may programmatically determine a range's cardinality using Cardinality#forRange(Cardinality start, Cardinality end, Locale locale) as shown below.
// Latvian has a number of interesting range rules
// ZERO-ZERO -> OTHER
Cardinality cardinality = Cardinality.forRange(Cardinality.ZERO, Cardinality.ZERO, Locale.forLanguageTag("lv"));
assertEquals(Cardinality.OTHER, cardinality);
// ZERO-ONE -> ONE
cardinality = Cardinality.forRange(Cardinality.ZERO, Cardinality.ONE, Locale.forLanguageTag("lv"));
assertEquals(Cardinality.ONE, cardinality);Some languages choose word forms based on the sound that follows (e.g. English a/an, Spanish el agua, Italian lo studente). Lokalized supports these via phonetic categories and a user-provided resolver.
Lokalized supports these values:
PHONETIC_VOWELPHONETIC_CONSONANTPHONETIC_H_SILENTPHONETIC_H_ASPIRATEDPHONETIC_S_IMPUREPHONETIC_ZPHONETIC_GNPHONETIC_PSPHONETIC_PNPHONETIC_XPHONETIC_GLIDE_YPHONETIC_GLIDE_WPHONETIC_STRESSED_APHONETIC_SOLARPHONETIC_LUNARPHONETIC_OTHER
Lokalized provides a Phonetic type which enumerates supported phonetic categories. To use phonetics, supply a PhoneticResolver when building Strings and use PHONETIC_* values in your translations file. The resolver receives both the term and its locale.
Let's model I received a {{noun}}.:
{
"I received a {{noun}}." : {
"translation" : "I received {{article}} {{noun}}.",
"placeholders" : {
"article" : {
"value" : "noun",
"translations" : {
"PHONETIC_VOWEL" : "an",
"PHONETIC_CONSONANT" : "a"
}
}
}
}
}Now, ensure we have translations like an honor and a gift:
Strings strings = Strings.withFallbackLocale(Locale.forLanguageTag("en"))
.localizedStringSupplier(() -> LocalizedStringLoader.loadFromClasspath("strings"))
// Plug in a custom resolver here. You would bring your own "startsWithVowelSound" implementation
.phoneticResolver((term, locale) -> startsWithVowelSound(term, locale) ? Phonetic.VOWEL : Phonetic.CONSONANT)
.localeSupplier(matcher -> Locale.forLanguageTag("en"))
.build();
assertEquals("I received an honor.", strings.get("I received a {{noun}}.", Map.of("noun", "honor")));
assertEquals("I received a gift.", strings.get("I received a {{noun}}.", Map.of("noun", "gift")));Now, for Spanish:
{
"I received a {{noun}}." : {
"translation" : "Recibi {{article}} {{noun}}.",
"placeholders" : {
"article" : {
"value" : "noun",
"translations" : {
"PHONETIC_STRESSED_A" : "el",
"PHONETIC_OTHER" : "la"
}
}
}
}
}...and its PhoneticResolver:
// Special "Stressed-A" support for Spanish languages
PhoneticResolver spanishResolver = (term, locale) -> {
if (!"es".equals(locale.getLanguage()))
return Phonetic.OTHER;
String normalized = term.toLowerCase(Locale.ROOT);
// It is your responsibility to define this set
return Set.of("acta", "arma", "hacha").contains(normalized)
? Phonetic.STRESSED_A
: Phonetic.OTHER;
};
Strings strings = Strings.withFallbackLocale(Locale.forLanguageTag("es"))
.localizedStringSupplier(() -> LocalizedStringLoader.loadFromClasspath("strings"))
.phoneticResolver(spanishResolver)
.localeSupplier(matcher -> Locale.forLanguageTag("es"))
.build();
assertEquals("Recibi el acta.", strings.get("I received a {{noun}}.", Map.of("noun", "acta")));
assertEquals("Recibi la carta.", strings.get("I received a {{noun}}.", Map.of("noun", "carta")));For example: 1st, 2nd, 3rd, 4th, ...
Similar to plural cardinality, ordinal rules vary widely across languages.
Lokalized supports these values according to CLDR 48 rules:
Again, like cardinal values, ordinals do not necessarily map to the named number. For example, ORDINALITY_ONE might apply to any number that ends in 1.
ORDINALITY_OTHER: Matches every number (CLDR defines no category-dependent ordinal variation for Spanish)
ORDINALITY_ONE: Matches 1, 21, 31, ... (e.g.1st prize)ORDINALITY_TWO: Matches 2, 22, 32, ... (e.g.22nd prize)ORDINALITY_FEW: Matches 3, 23, 33, ... (e.g.33rd prize)ORDINALITY_OTHER: Everything else (e.g.12th prize)
ORDINALITY_MANY: Matches 8, 11, 80, 800 (e.g.Prendi l’8° a destra)ORDINALITY_OTHER: Everything else (e.g.Prendi la 7° a destra)
Lokalized provides an Ordinality type which encapsulates ordinal functionality.
You may programmatically determine ordinality using Ordinality#forNumber(Number number, Locale locale) as shown below.
// e.g. "1st"
Ordinality ordinality = Ordinality.forNumber(1, Locale.forLanguageTag("en"));
assertEquals(Ordinality.ONE, ordinality);
// e.g. "2nd"
ordinality = Ordinality.forNumber(2, Locale.forLanguageTag("en"));
assertEquals(Ordinality.TWO, ordinality);
// e.g. "3rd"
ordinality = Ordinality.forNumber(3, Locale.forLanguageTag("en"));
assertEquals(Ordinality.FEW, ordinality);
// e.g. "21st"
ordinality = Ordinality.forNumber(21, Locale.forLanguageTag("en"));
assertEquals(Ordinality.ONE, ordinality);
// e.g. "27th"
ordinality = Ordinality.forNumber(27, Locale.forLanguageTag("en"));
assertEquals(Ordinality.OTHER, ordinality);Ordinality#forOperands(PluralOperands operands, Locale locale) is also available for advanced CLDR operand cases.
Ordinality#getSupportedLocaleTags() returns the language tags represented directly in the pinned CLDR plural-rule data. Concrete region- or script-qualified languages can also work through rule fallback; use Ordinality#supportedOrdinalitiesForLocale(Locale) to probe one.
Lokalized's cardinality, ordinality, cardinality-range, locale matching, locale-tag validation, and right-to-left script behavior is generated from pinned Unicode CLDR 48.2 source data.
Pinned CLDR resources live under src/test/resources/cldr/48.2. That directory records the upstream source URLs, SHA-256 checksums, license note, refresh commands, and generator command. Generated runtime data is checked in under src/main/java, exhaustive conformance fixtures are checked in under src/test/java, and the website-facing grouped plural data (formatVersion: 1) is checked in at src/build/resources/cldr/cldr-plural-data.json.
After refreshing CLDR data, regenerate the checked-in sources and run the conformance tests before committing:
javac -d target/cldr-generator src/build/java/com/lokalized/cldr/CldrDataGenerator.java
java -cp target/cldr-generator com.lokalized.cldr.CldrDataGenerator
java -cp target/cldr-generator com.lokalized.cldr.CldrDataGenerator --check
mvn -q testThe normal Maven build compiles the generator as test code and performs a byte-for-byte drift check across every generated Java and JSON artifact. The generator is not packaged in the runtime JAR.
Locale fallback and final failure handling are separate decisions. After each unsuccessful locale attempt,
TranslationFallbackPolicy decides whether
to try the next candidate. Only after fallback stops or candidates are exhausted does Lokalized ask the configured
TranslationFailureHandler what to return
or throw. The default policy falls back for missing translations and unmatched alternatives, but stops on runtime
resolution failures so a corrupt translation cannot be silently hidden by a different locale. The default handler is
TranslationFailureHandler.returnKey(),
which silently returns the lookup key with caller-supplied placeholders interpolated into it.
Strings strings = Strings.withFallbackLocale(Locale.forLanguageTag("en"))
.localizedStringSupplier(() -> LocalizedStringLoader.loadFromClasspath("strings"))
.localeSupplier((matcher) -> matcher.bestMatchFor(Locale.US))
.translationFailureHandler(TranslationFailureHandler.throwException())
.build();Built-in handler factories are:
TranslationFailureHandler.returnKey()- silently returns the key with supplied placeholders interpolatedTranslationFailureHandler.returnKey(Consumer<? super TranslationFailure>)- sends the structured failure to the supplied observer, then returns the interpolated keyTranslationFailureHandler.throwException()- throwsMissingTranslationExceptionfor missing translations and rethrows runtime resolution failures
Built-in fallback policies are:
fallbackOnMissingTranslationOrNoMatchingAlternative()- the safe defaultfallbackOnAnyFailure()- preserves Lokalized 2.x behavior, including fallback after resolution failuresneverFallback()- stops after the first failed locale attempt
Global policies are configured with
Strings.Builder.translationFallbackPolicy(...);
TranslationOptions.Builder.translationFallbackPolicy(...)
overrides it for one lookup. Custom policies and handlers may be called concurrently and must be thread-safe.
Failure reasons distinguish a key that is absent from every attempted candidate locale
(MISSING_TRANSLATION),
a present alternatives-only key for which no condition matched
(NO_MATCHING_ALTERNATIVE),
and a translation that could not be evaluated or interpolated
(RESOLUTION_FAILURE).
Only resolution failures carry a runtime cause.
An evaluated expression-fragment predicate or selected-fragment interpolation failure is a resolution failure, so the
default fallback policy stops; fallbackOnAnyFailure()
may continue to another locale. An expression-selected fragment always has a default translation and therefore
cannot itself cause NO_MATCHING_ALTERNATIVE.
Both returnKey() variants also handle runtime resolution failures by returning the interpolated key. Use throwException() or a custom handler that throws on TranslationFailureReason.RESOLUTION_FAILURE in development and test environments if you want broken placeholder rules, expressions, or custom resolvers to surface immediately.
Custom handlers inspect a TranslationFailure and return a TranslationFailureResponse. For example, you might fail softly for missing translations but throw for resolution failures, which usually indicate a broken placeholder, expression, or language-form rule:
Strings strings = Strings.withFallbackLocale(Locale.forLanguageTag("en"))
.localizedStringSupplier(() -> LocalizedStringLoader.loadFromClasspath("strings"))
.localeSupplier((matcher) -> matcher.bestMatchFor(Locale.US))
.translationFailureHandler((failure) -> {
if (failure.getReason() == TranslationFailureReason.RESOLUTION_FAILURE)
return TranslationFailureResponse.throwException();
return TranslationFailureResponse.returnKey();
})
.build();TranslationFailure exposes
getKey(),
getLookupLocale(), optional
getLocaleMatchResult(),
getAttemptedLocales(),
caller-supplied placeholders,
getReason(), and optional
getCause(). Its
getMessage() is a redacted,
human-readable summary containing only the key, lookup locale, reason, and attempted locales. It omits caller-supplied
placeholder values and runtime-cause messages; inspect getCause() separately when your application intends to expose
that detail. Placeholder values can contain user data, so avoid recording
failure.getPlaceholders()
unless your application has explicitly approved that.
Most callers can use get(...)
and configure locale selection with
localeSupplier(...)
and bestMatchFor(...). Call
getResult(...)
only when the caller also needs diagnostics:
TranslationResult result = strings.getResult(
"I read {{bookCount}} books.",
Map.of("bookCount", 3),
TranslationOptions.forLanguageRanges(LanguageRange.parse("pt-PT,pt;q=0.8"))
);
// Returned text, e.g. "Li 3 livros."
String message = result.getTranslation();
// Negotiated locale used to begin lookup, e.g. pt-PT
Locale lookupLocale = result.getLookupLocale();
// Locale that supplied the translation, e.g. Optional[pt-PT]
Optional<Locale> resolvedLocale = result.getResolvedLocale();
// Ordered locales actually tried, e.g. [pt-PT]
List<Locale> attemptedLocales = result.getAttemptedLocales();
// Whether negotiation or per-key resolution fell back, e.g. false
Boolean usedFallback = result.isFallback();
// Negotiation details when available, e.g. an exact pt-PT match
Optional<LocaleMatchResult> localeMatch = result.getLocaleMatchResult();TranslationResult uses
TranslationResultStatus to distinguish
translated, returned-key, and handler-returned-string outcomes, and exposes the final failure reason and resolution cause
when applicable. Per-call language ranges, such as those supplied through
TranslationOptions.forLanguageRanges(...),
are preserved in its optional LocaleMatchResult.
localeMatchSupplier(...)
can retain the original negotiation diagnostics when an application already has a validated, bounded list of
LanguageRange
values. It passes through the result of strict
matchFor(...); raw request
headers should use the fail-soft
bestMatchForAcceptLanguage(...)
example above. Its immutable
LocaleMatchResult preserves the requested ranges,
locales considered, winning range and quality, match kind
(LocaleMatchType), and an explicit unmatched state. If
every loaded locale is excluded with q=0, translation lookup can still use the configured fallback while the result
accurately reports that negotiation found no acceptable match. The simpler localeSupplier(...bestMatchFor(...)) path
remains appropriate when an application only needs the selected locale.
- Each localized strings file must be UTF-8 encoded and named according to the appropriate IETF BCP 47 language tag, such as
enorzh-TW(an optional.jsonsuffix likeen.jsonis also accepted; do not provide both for the same locale) - A blank or BOM-only file is invalid; use
{}for an intentionally empty localized strings file - The file must contain a single toplevel JSON object
- The object's keys are the translation keys, e.g.
"I read {{bookCount}} books." - The value for a translation key can be a string (simple cases) or an object (complex cases)
With formalities out of the way, let's examine an example UK English (en-GB) localized strings file, which contains a single translation. We can use the string form shorthand to concisely express our intent:
{
"I am going on vacation." : "I am going on holiday."
}This is equivalent to the more verbose object form, which we don't need in this situation.
{
"I am going on vacation." : {
"translation" : "I am going on holiday."
}
}In addition to translation, each object form supports 3 additional keys: commentary, placeholders, and alternatives.
All 4 are optional, with the stipulation that you must provide either a translation or at least one alternatives value.
A JSON Schema for localized strings files is packaged in the jar at schema/lokalized-strings.schema.json and is available at src/main/resources/schema/lokalized-strings.schema.json.
The schema validates file structure, placeholder shapes, known language-form names, and alternatives. It does not parse alternative expression syntax; Lokalized validates expression syntax when strings are loaded. Completeness of locale-specific cardinality and ordinality maps is not enforced at load time; an incomplete file still loads, but Lokalized emits a warning when a cardinality- or ordinality-driven placeholder omits a language form its locale requires per CLDR (for example, a Russian file that omits CARDINALITY_MANY). Values that resolve to a missing form surface during resolution according to the configured failure handler.
Validation warnings are delivered to a LocalizedStringWarningHandler, which each LocalizedStringLoader.load* method accepts as an optional argument:
// Default: load successfully and emit no warning callbacks
Map<Locale, Set<LocalizedString>> strings = LocalizedStringLoader.loadFromClasspath("strings");
// Receive structured warnings directly
List<LocalizedStringWarning> warnings = new ArrayList<>();
LocalizedStringLoader.loadFromClasspath("strings", warning -> {
warnings.add(warning);
});
// Fail fast: treat any incomplete file as a load error (useful in tests/CI)
LocalizedStringLoader.loadFromClasspath("strings", LocalizedStringWarningHandler.throwException());Warnings are silently ignored when no handler is supplied. Each LocalizedStringWarning exposes structured detail (getType(), getSource(), optional getLocale(), optional getKey(), optional getPlaceholder(), and getMissingLanguageForms()) alongside a human-readable getMessage(). Resource-level warnings such as an invalid classpath locale filename omit locale, key, and placeholder context.
The commentary field stores translator-facing context and is never rendered at runtime. It is particularly useful for contextual keys, strings whose meaning depends on their product surface, and documenting the names and types of application-supplied placeholder values.
{
"I am going on vacation." : {
"commentary" : "This is one of the options in the user's status update dropdown.",
"translation" : "I am going on holiday."
}
}A placeholder is any translation value enclosed in a pair of "mustaches" - {{PLACEHOLDER_NAME_HERE}}.
Placeholder names must start with a Unicode letter or underscore. Subsequent characters may be Unicode letters,
Unicode numbers, Unicode combining marks, underscores, or hyphens. Whitespace inside mustaches is not allowed, so
write {{bookCount}}, not {{ bookCount }}.
Unicode letter, number, and mark membership follows the Unicode tables in the executing JDK's
Pattern implementation;
independent JSON Schema validators likewise use their own Unicode tables. For portable files, author identifiers for
the oldest JDK and schema validator deployed by your application. The ASCII subset
[A-Za-z_][A-Za-z0-9_-]* is portable across supported JDK versions and schema engines.
To render a literal placeholder instead of resolving it, escape the opening delimiter with a backslash. In JSON this means writing \\{{name}}, which renders as {{name}} and is not resolved against the placeholder context. You can also write \\}} for a literal closing delimiter, or \\\\{{name}} when you need a literal backslash immediately before a live placeholder.
Add as many as the translation needs, subject to the configured translation-node limit for localized strings files.
Placeholder values are initially specified by application code - they are the context that is passed in at string evaluation time.
When a reachable generated definition has the same name as a caller value, the generated value wins during output interpolation. Prefer distinct input and generated-output names unless that override is intentional.
For right-to-left resolved locales, Lokalized wraps application-supplied placeholder values with Unicode First Strong Isolate (U+2068) and Pop Directional Isolate (U+2069) by default. This prevents left-to-right values such as product codes, user names, and numbers from reordering nearby punctuation in Arabic, Hebrew, and other RTL translations. Placeholder fragments defined by the localized strings file, such as plural word choices, are not isolated.
Suppose the Arabic translation for Shipment is تم تجهيز {{code}}. By default, the caller-supplied code value is isolated:
String message = strings.get("Shipment", Map.of("code", "ACME-42"));
// U+2068 FIRST STRONG ISOLATE begins bidirectional isolation around the caller-supplied value
// U+2069 POP DIRECTIONAL ISOLATE ends that bidirectional isolation
assertEquals("تم تجهيز \u2068ACME-42\u2069", message);Disable this behavior with BidiIsolation for plain-text sinks that cannot accept Unicode bidi controls:
TranslationOptions options = TranslationOptions.builder()
.bidiIsolation(BidiIsolation.NONE)
.build();
String message = strings.get("Shipment", Map.of("code", "ACME-42"), options);
assertEquals("تم تجهيز ACME-42", message);The default BidiIsolation.RTL_LOCALES policy isolates caller values only when the resolved translation locale is
right-to-left. Use BidiIsolation.ALWAYS when caller-supplied right-to-left text can also appear inside left-to-right
translations; already balanced isolate controls are preserved rather than nested again.
In the below example of an en localized strings file, the application code provides the bookCount value and the localized strings file introduces a books value to aid final translation.
{
"I read {{bookCount}} books." : {
"translation" : "I read {{bookCount}} {{books}}.",
"placeholders" : {
"books" : {
"value" : "bookCount",
"translations" : {
"CARDINALITY_ONE" : "book",
"CARDINALITY_OTHER" : "books"
}
}
}
}
}Each placeholders object key is the name of a generated placeholder - books, in this example. A definition has one
of three mutually exclusive shapes: value plus translations, cardinality range plus translations, or
translation with optional expression-selected alternatives. The corresponding programmatic types are the closed
LocalizedString.PlaceholderDefinition
hierarchy, LocalizedString.LanguageFormTranslation,
LocalizedString.ExpressionTranslation,
and its ordered LocalizedString.ExpressionAlternative
entries.
valueis the placeholder value to examine. It may be aNumber,PluralOperands,Cardinality,Ordinality,Gender,GrammaticalCase,Definiteness,Classifier,Formality,Clusivity,Animacy,Phonetic, orCharSequencetype. Lokalized convertsNumberandPluralOperandsinstances to the appropriateCardinalityorOrdinalityaccording to the language's rules, accepts pre-resolvedCardinalityandOrdinalityvalues directly, and convertsCharSequenceinstances toPhoneticusing yourPhoneticResolverwith the current locale. The same cardinality input forms are accepted for range endpoints.translationsis a set of language rules against which to evaluatevalueand provide a translation
Here, the value of bookCount is evaluated against the specified cardinality rules and the result is placed into books. For example, if application code passes in 1 for bookCount, this matches CARDINALITY_ONE and book is the value of the books placeholder. If application code passes in a different value, CARDINALITY_OTHER is matched and books is used.
Supported values for translations are Cardinality, Ordinality, Gender, GrammaticalCase, Definiteness, Classifier, Formality, Clusivity, Animacy, and Phonetic types.
You may not mix language forms in the same translations object. For example, it is illegal to specify both CARDINALITY_ONE and GENDER_MASCULINE.
Placeholder rules are strict: if your application supplies or resolves a language-form value that is not present in translations, the lookup is treated as a resolution failure and your configured TranslationFailureHandler decides what happens.
Lokalized evaluates only placeholders defined by the localized strings file that are reachable from the selected translation.
A selected language-form value may itself reference application-supplied placeholders or other
placeholders defined by the localized strings file; those fragments are expanded recursively. Cycles, excessive nesting, and
interpolated output above the configured limit fail resolution clearly. The default is 262,144 UTF-16 code units,
and applications can opt up to the 1,048,576-code-unit hard ceiling with
TranslationRuntimeLimits. Application-supplied
values remain opaque and are never reinterpreted as template syntax, even when a value contains text such as
{{name}}.
Use a generated fragment with translation and ordered alternatives when a small, reusable portion of a message
depends on an exact value, threshold, or compound business rule. translation is the required default. The first
matching alternative wins; later expressions are not evaluated. Omitting alternatives creates a message-scoped
constant or composed fragment, such as "productName": { "translation": "Firefox" }. Each alternative array element
contains exactly one expression-to-string member; nested localized-string objects are not permitted there. Template
members (translation, alternatives) cannot be mixed with language-form members (value, range, translations)
in one placeholder definition.
This example has three result-summary cases and an independent two-case timing axis. Factoring those decisions into two fragments avoids six coordinated whole-message alternatives:
{
"Search completed." : {
"translation" : "Found {{resultSummary}} {{timing}}.",
"placeholders" : {
"resultSummary" : {
"translation" : "{{formattedResultCount}} {{resultNoun}}",
"alternatives" : [
{
"resultCount == 0" : "no results"
},
{
"resultCount >= resultLimit" : "at least {{formattedResultLimit}} results"
}
]
},
"timing" : {
"translation" : "in {{formattedDuration}}",
"alternatives" : [
{
"elapsedMilliseconds < 1000" : "instantly"
}
]
},
"resultNoun" : {
"value" : "resultCount",
"translations" : {
"CARDINALITY_ONE" : "result",
"CARDINALITY_OTHER" : "results"
}
}
}
}
}The same localized strings file produces all six combinations. Raw numeric values drive expressions and cardinality; separately formatted strings are used only for display:
assertEquals("Found no results instantly.", strings.get("Search completed.", Map.of(
"resultCount", Long.valueOf(0), "resultLimit", Long.valueOf(100),
"elapsedMilliseconds", Long.valueOf(250), "formattedResultCount", "0",
"formattedResultLimit", "100", "formattedDuration", "0.25 seconds")));
assertEquals("Found no results in 1.5 seconds.", strings.get("Search completed.", Map.of(
"resultCount", Long.valueOf(0), "resultLimit", Long.valueOf(100),
"elapsedMilliseconds", Long.valueOf(1_500), "formattedResultCount", "0",
"formattedResultLimit", "100", "formattedDuration", "1.5 seconds")));
assertEquals("Found at least 100 results instantly.", strings.get("Search completed.", Map.of(
"resultCount", Long.valueOf(100), "resultLimit", Long.valueOf(100),
"elapsedMilliseconds", Long.valueOf(250), "formattedResultCount", "100",
"formattedResultLimit", "100", "formattedDuration", "0.25 seconds")));
assertEquals("Found at least 100 results in 1.5 seconds.", strings.get("Search completed.", Map.of(
"resultCount", Long.valueOf(100), "resultLimit", Long.valueOf(100),
"elapsedMilliseconds", Long.valueOf(1_500), "formattedResultCount", "100",
"formattedResultLimit", "100", "formattedDuration", "1.5 seconds")));
assertEquals("Found 2 results instantly.", strings.get("Search completed.", Map.of(
"resultCount", Long.valueOf(2), "resultLimit", Long.valueOf(100),
"elapsedMilliseconds", Long.valueOf(250), "formattedResultCount", "2",
"formattedResultLimit", "100", "formattedDuration", "0.25 seconds")));
assertEquals("Found 2 results in 1.5 seconds.", strings.get("Search completed.", Map.of(
"resultCount", Long.valueOf(2), "resultLimit", Long.valueOf(100),
"elapsedMilliseconds", Long.valueOf(1_500), "formattedResultCount", "2",
"formattedResultLimit", "100", "formattedDuration", "1.5 seconds")));Expression-fragment predicates and whole-message predicates both read the same immutable snapshot of caller input and
use the locale of the localized strings file for the candidate locale. Generated values never become predicate operands. Numeric
ordering requires a Number or
numeric PluralOperands; a formatted string such as
"1,000" is display text, not a numeric operand. More generally, expression String
and other CharSequence
operands are phonetic inputs: Lokalized resolves them through your
PhoneticResolver for comparison with
PHONETIC_* constants or explicit Phonetic values. They are not numeric values or general-purpose string literals,
and two raw CharSequence placeholders cannot be compared for textual equality. Missing, null, and incompatible
operands are resolution failures, not implicit non-matches.
Use language-form definitions for grammatical categories and expression fragments for exact, threshold, or compound
rules. The distinction matters: count == 1 is an exact numeric test, while count == CARDINALITY_ONE is a
locale-sensitive grammatical classification. Russian classifies values such as 21 as ONE, while French and
Brazilian Portuguese can classify 0 as ONE. Keep a whole-message alternative when a decision changes word order or
agreement so broadly that a fragment boundary would be unsafe.
Generated-placeholder definitions inherit down only the selected whole-message alternative path. Definitions from the
root and every selected intermediate branch remain visible at the terminal translation. A nearer child definition
replaces the complete same-named ancestor definition - language forms and expression alternatives are never merged - and
unselected siblings are invisible. Effective precedence is nearest selected child > selected ancestor > caller value for output interpolation.
Selection is completed before generated placeholders resolve, so an inherited parent fragment is late-bound to any dependency overridden by the selected child. Only definitions reachable from the selected template and selected fragment results are resolved; shadowed definitions, unselected fragment dependencies, and unused definitions consume no lookup-time expansion work.
There is one deliberate exception to generated-over-caller output precedence: every language-form value,
range.start, and range.end name always reads the raw caller input. A generated definition named count may supply
the rendered {{count}}, while another definition with "value": "count" still classifies the caller's raw count.
Use distinct input and output names, such as count and countText, to avoid surprising localized strings behavior. A generated value
cannot satisfy a missing selector input.
Expression-selected and language-form fragments share recursive dependency expansion, cycle detection, depth and
output limits, and bidi behavior. Translation-owned fragment text is not isolated; caller values interpolated inside
it follow the configured BidiIsolation policy. A
matched predicate whose fragment later fails does not fall through to a later predicate or the default. Predicate and
fragment interpolation errors are RESOLUTION_FAILURE
events; because translation is mandatory, an expression-selected fragment cannot itself produce
NO_MATCHING_ALTERNATIVE.
The placeholder structure is slightly different for cardinality ranges. A range property is introduced and requires both a start and end value.
{
"The meeting will be {{minHours}}-{{maxHours}} hours long." : {
"translation" : "La réunion aura une durée de {{minHours}} à {{maxHours}} {{heures}}.",
"placeholders" : {
"heures" : {
"range" : {
"start" : "minHours",
"end" : "maxHours"
},
"translations" : {
"CARDINALITY_ONE" : "heure",
"CARDINALITY_OTHER" : "heures"
}
}
}
}
}Here, the cardinalities of minHours and maxHours are evaluated to determine the overall cardinality of the range, which is used to select the appropriate value in translations.
You are prohibited from supplying both range and value fields - use range only for cardinality ranges and value otherwise.
Each value in an alternatives array can use the string shorthand or a full object with translation, commentary, placeholders, and more alternatives. A selected alternative can therefore refine itself with additional rules.
In this recruiter notification, gender matters only when exactly one applicant is present. Nesting writes applicantCount == 1 once, groups the gender-specific wording beneath it, and retains a neutral default for Gender.COMMON or Gender.NEUTER.
A flat ordered list using && would be equivalent. Recursion adds hierarchy, branch-local defaults, and inherited definitions rather than additional Boolean power. Application code supplies applicantCount and applicantGender as a Gender value.
{
"You have {{applicantCount}} new applicants." : {
"translation" : "Tienes {{applicantCount}} candidaturas nuevas.",
"alternatives" : [
{
"applicantCount == 0" : "No tienes candidaturas nuevas."
},
{
"applicantCount == 1" : {
"translation" : "Tienes una candidatura nueva.",
"alternatives" : [
{
"applicantGender == GENDER_MASCULINE" : "Tienes un candidato nuevo."
},
{
"applicantGender == GENDER_FEMININE" : "Tienes una candidata nueva."
}
]
}
}
]
}
}String message = strings.get("You have {{applicantCount}} new applicants.", Map.of(
"applicantCount", 1,
"applicantGender", Gender.FEMININE
));
assertEquals("Tienes una candidata nueva.", message);
message = strings.get("You have {{applicantCount}} new applicants.", Map.of(
"applicantCount", 1,
"applicantGender", Gender.COMMON
));
assertEquals("Tienes una candidatura nueva.", message);Alternative evaluation follows these rules:
- At each level, expressions are evaluated according to their order in the list, halting at the first match
- Within a matched branch, nested alternatives are evaluated before that branch's default
translation - Once an expression matches, evaluation stays within that branch; an unmatched nested subtree does not fall through to a later sibling
- Placeholder definitions declared by the root and each selected branch are inherited by descendants; the nearest selected definition replaces a same-named ancestor definition as a complete unit
- Predicates read only the immutable caller-input snapshot. Generated placeholder values do not become operands
- If no expression matches and no default
translationis present in any attempted candidate locale, failure handlers receiveTranslationFailureReason.NO_MATCHING_ALTERNATIVE
Fragment alternatives and recursive alternatives use the same bounded expression language. Each object in an alternatives array contains exactly one expression. For example:
gender == GENDER_MASCULINE && (bookCount > 10 || magazineCount > 20)
Standard boolean operator precedence applies: && binds tighter than ||.
Numeric literals and expressions are parsed and checked against hard ceilings when translations are loaded. Numeric
literals use the same precision and absolute-scale limits as
PluralOperands, so an exponent cannot defer
unbounded decimal materialization until lookup time. The configured runtime policy is applied when Strings compiles
the localized strings. Expressions are limited to 2,048 source characters, 256 tokens, and 32 nested groups by default. Strings
applications may configure these with
TranslationRuntimeLimits, up to hard
ceilings of 4,096 characters, 512 tokens, and 64 nested groups.
Lokalized will automatically evaluate cardinality and ordinality for numbers or PluralOperands if required by the expression. PluralOperands preserve their original signed value for ordinary numeric comparisons, while CLDR plural-category evaluation continues to use the absolute value. For example, in English, if application code supplies bookCount of 50, both of these expressions evaluate to true:
bookCount == CARDINALITY_OTHER
bookCount == 50
The supported comparison operators for cardinality, ordinality, gender, and phonetic forms are == and !=. You cannot say bookCount < CARDINALITY_FEW, for example.
EXPRESSION = OR_EXPRESSION ;
OR_EXPRESSION = AND_EXPRESSION { "||" AND_EXPRESSION } ;
AND_EXPRESSION = PRIMARY_EXPRESSION { "&&" PRIMARY_EXPRESSION } ;
PRIMARY_EXPRESSION = COMPARISON | "(" EXPRESSION ")" ;
COMPARISON = OPERAND COMPARISON_OPERATOR OPERAND ;
OPERAND = VARIABLE | LANGUAGE_FORM | NUMBER ;
LANGUAGE_FORM = CARDINALITY | ORDINALITY | GENDER | GRAMMATICAL_CASE
| DEFINITENESS | CLASSIFIER | FORMALITY | CLUSIVITY
| ANIMACY | PHONETIC ;
CARDINALITY = "CARDINALITY_ZERO" | "CARDINALITY_ONE" | "CARDINALITY_TWO"
| "CARDINALITY_FEW" | "CARDINALITY_MANY" | "CARDINALITY_OTHER" ;
ORDINALITY = "ORDINALITY_ZERO" | "ORDINALITY_ONE" | "ORDINALITY_TWO"
| "ORDINALITY_FEW" | "ORDINALITY_MANY" | "ORDINALITY_OTHER" ;
GENDER = "GENDER_MASCULINE" | "GENDER_FEMININE"
| "GENDER_COMMON" | "GENDER_NEUTER" ;
GRAMMATICAL_CASE = "CASE_NOMINATIVE" | "CASE_ACCUSATIVE"
| "CASE_GENITIVE" | "CASE_DATIVE" | "CASE_INSTRUMENTAL"
| "CASE_LOCATIVE" | "CASE_PREPOSITIONAL"
| "CASE_VOCATIVE" | "CASE_ABLATIVE" ;
DEFINITENESS = "DEFINITENESS_DEFINITE" | "DEFINITENESS_INDEFINITE"
| "DEFINITENESS_CONSTRUCT" ;
CLASSIFIER = "CLASSIFIER_GENERAL" | "CLASSIFIER_PERSON" | "CLASSIFIER_ANIMAL"
| "CLASSIFIER_LONG_THIN" | "CLASSIFIER_FLAT" | "CLASSIFIER_BOUND"
| "CLASSIFIER_MACHINE" | "CLASSIFIER_VEHICLE" ;
FORMALITY = "FORMALITY_CASUAL" | "FORMALITY_INFORMAL" | "FORMALITY_FORMAL"
| "FORMALITY_HUMBLE" | "FORMALITY_HONORIFIC" ;
CLUSIVITY = "CLUSIVITY_INCLUSIVE" | "CLUSIVITY_EXCLUSIVE" ;
ANIMACY = "ANIMACY_ANIMATE" | "ANIMACY_INANIMATE" ;
PHONETIC = "PHONETIC_VOWEL" | "PHONETIC_CONSONANT"
| "PHONETIC_H_SILENT" | "PHONETIC_H_ASPIRATED"
| "PHONETIC_S_IMPURE" | "PHONETIC_Z" | "PHONETIC_GN" | "PHONETIC_PS"
| "PHONETIC_PN" | "PHONETIC_X"
| "PHONETIC_GLIDE_Y" | "PHONETIC_GLIDE_W"
| "PHONETIC_STRESSED_A"
| "PHONETIC_SOLAR" | "PHONETIC_LUNAR"
| "PHONETIC_OTHER" ;
NUMBER = [ SIGN ],
( DIGITS, [ ".", { DIGIT } ] | ".", DIGITS ),
[ EXPONENT ] ;
EXPONENT = ( "e" | "E" ), [ SIGN ], DIGITS ;
SIGN = "+" | "-" ;
DIGITS = DIGIT, { DIGIT } ;
DIGIT = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" ;
VARIABLE = ( Unicode letter | "_" )
{ Unicode letter | Unicode number | Unicode combining mark
| "_" | "-" } ;
COMPARISON_OPERATOR = "<" | ">" | "<=" | ">=" | "==" | "!=" ;Comparison operators bind more tightly than &&, which binds more tightly than ||. Parentheses override that precedence.
Expressions ignore ASCII space, horizontal tab, carriage return, line feed, and form feed between tokens. Other Unicode whitespace and separator characters are rejected rather than silently skipped.
Built-in language-form constants are reserved in alternative expressions. A token like
CARDINALITY_ONE,
GENDER_MASCULINE,
CASE_DATIVE,
DEFINITENESS_DEFINITE,
CLASSIFIER_PERSON,
FORMALITY_FORMAL,
CLUSIVITY_INCLUSIVE,
ANIMACY_ANIMATE, or
PHONETIC_VOWEL is parsed as a constant, not as a
placeholder variable. Placeholder names may not use built-in constant names.
- Evaluation of bounded "normal" infix expressions (can be nested/parenthesized)
- Comparison of supported language forms, phonetic forms, plural and ordinal forms, and literal numeric values against each other or user-supplied variables
- The unary
!operator - String literals, Boolean literals, or explicit
nulloperands - Textual equality between two raw
CharSequenceplaceholder values; compare phonetic input withPHONETIC_* - Functions or expressions that return arbitrary values
- A cardinality range construct (to be added in a future release)
Strings provides read-only inspection helpers for audit and tooling use:
Strings strings = Strings.withFallbackLocale(FALLBACK_LOCALE)
.localizedStringSupplier(() -> LocalizedStringLoader.loadFromFilesystem(Paths.get("my-directory")))
.localeSupplier((matcher) -> matcher.bestMatchFor(Locale.US))
.build();
Set<Locale> supportedLocales = strings.getSupportedLocales();
Set<String> englishKeys = strings.getKeysForLocale(Locale.forLanguageTag("en"));
Set<String> missingFrenchKeys =
strings.getMissingKeys(Locale.forLanguageTag("en"), Locale.forLanguageTag("fr"));getKeysForLocale(Locale) and
getMissingKeys(Locale sourceLocale, Locale targetLocale)
are intentionally strict: inspected locales must be supported, and unsupported locales throw
IllegalArgumentException.
Use getSupportedLocales() first when
you need to probe availability.
Ultimately, it is up to you and your team how best to name your localization keys. Lokalized does not impose key naming constraints.
There are two common approaches - natural language and contextual. Some benefits and drawbacks of each are listed below to help you make the best decision for your situation.
For example: "I read {{bookCount}} books."
- Any developer can create a key by writing a phrase in her native language - no need to coordinate with others or choose arbitrary names
- Placeholders are encoded directly in the key and serve as "automatic" documentation for translators
- There is always a sensible default fallback in the event that a translation is missing
- Context is lost; the same text on one screen might have a completely different meaning on another
- Not suited for large amounts of text, like a software licensing agreement
- Small changes to text require updating every localized strings file since keys are not "constant"
For example: "Checkout.Title", "Checkout.Submit", and "Checkout.Cancel".
{
"Checkout.Title" : {
"commentary" : "Heading shown at the top of the checkout page.",
"translation" : "Checkout"
},
"Checkout.Submit" : {
"commentary" : "Primary button that submits the order.",
"translation" : "Place order"
},
"Checkout.Cancel" : {
"commentary" : "Secondary button that returns the user to the cart.",
"translation" : "Return to cart"
}
}- It is possible to target a specific product surface or component, which enforces translation context
- Perfect for big chunks of text like legal disclaimers
- "Constant" keys means translations can change without affecting code
- You must come up with names for every key and cross-reference in your localized strings files
- Placeholders are not encoded in the key and must be communicated to translators through some other mechanism
- Requires diligent recordkeeping and inter-team communication ("are our iOS and Android apps using the same keys or are we duplicating effort?")
- There is no default language fallback if no translation is present; users will see your contextual key onscreen
It's possible to cherrypick and create a hybrid solution. For example, you might use natural language keys in most cases but switch to contextual for legalese and other special cases.
Lokalized, ICU MessageFormat, MessageFormat 2 (MF2), Fluent, and gettext all handle variable substitution and ordinary plural selection. The meaningful differences appear when several runtime facts jointly control wording, a language needs agreement beyond plurals, or the application needs a strict runtime contract.
Classic ICU MessageFormat expresses a plural message compactly:
{bookCount, plural, one {I read # book.} other {I read # books.}}
The equivalent Lokalized file separates the sentence shape from the plural word choice:
{
"I read {{bookCount}} books." : {
"translation" : "I read {{bookCount}} {{books}}.",
"placeholders" : {
"books" : {
"value" : "bookCount",
"translations" : {
"CARDINALITY_ONE" : "book",
"CARDINALITY_OTHER" : "books"
}
}
}
}
}This baseline is not a differentiator: every format above handles it well. Lokalized's extra structure starts paying for itself when the message must coordinate several forms or isolate one agreement-sensitive fragment without duplicating every complete sentence.
| Concern | Lokalized | Other formats |
|---|---|---|
| Sparse compound rules | Ordered predicates can combine typed forms, exact numbers, thresholds, &&, ||, and parentheses. A rule can replace one fragment or the whole message, with the ordinary translation serving as the default. |
ICU MessageFormat nests select and plural; MF2 enumerates multi-selector variants; Fluent uses select expressions. Equivalent output is often possible, but inequalities and sparse cross-field exceptions generally need more variants, a custom selector, or a value precomputed by application code. gettext leaves non-plural selection to keys, contexts, or application logic. |
| Grammatical vocabulary | Cardinality, ordinality, gender, grammatical case, definiteness, classifiers, formality, clusivity, animacy, and phonetics are named, typed concepts shared by translation files and Java callers. | ICU and Fluent can use application-defined selector keys; Fluent terms can model case and other facets. MF2 custom selectors can add domain-specific behavior. The format itself does not supply Lokalized's complete vocabulary as one built-in contract. |
| Cardinality ranges | A generated placeholder accepts typed start and end values and selects the result using pinned CLDR plural-range data. | Range agreement is not a built-in selector in the compared core message syntaxes. MF2 documents it as a custom-selector use case; other approaches normally preprocess the range, add custom logic, or select a separate key. |
| Phonetic agreement | An application-supplied PhoneticResolver maps runtime text to typed onset categories for rules such as a/an, silent or aspirated h, Italian initial clusters, Spanish stressed a, and Arabic sun or moon letters. |
The application generally supplies a precomputed selector value or extends the runtime with a custom function or selector. |
| Runtime guarantees | Lokalized adds fail-fast compilation, bounded evaluation, structured diagnostics, deterministic locale matching, immutable thread-safe objects, and no runtime dependencies. | ICU, MF2, Fluent, and gettext primarily define translation behavior. Validation, resource limits, locale negotiation, concurrency, and dependency choices vary by implementation and integration. |
ICU's nested selectors, MF2's multi-selector matching and custom functions, and Fluent's selectors and parameterized terms are powerful. Lokalized's distinction is that its runtime provides the agreement concepts and operational guarantees above as one coherent, zero-dependency system, without requiring a project to invent custom selector conventions first.
- A translator needs direct control over case, gender, register, phonetics, or another typed form rather than an opaque application-generated flag.
- Most messages follow a default translation but a few compound predicates require natural whole-phrase rewrites.
- CLDR cardinality ranges or phonetic onset rules are real product requirements rather than theoretical edge cases.
- An application benefits from startup validation, bounded evaluation, deterministic locale fallback, and a dependency-free runtime.
Use the standard JDK formatters for dates, times, numbers, percentages, and currency. Choose ICU MessageFormat, MF2, or Fluent when an established translation workflow, rich formatting functions, or broad ecosystem tooling is the primary requirement. Choose gettext when PO-file tooling and existing translator workflows are the main constraint. Choose Lokalized when agreement logic is the difficult part and translators should own that logic without pushing it back into application code.
Each language reference page includes CLDR 48.2 cardinality, cardinality range, and ordinality data, plus generated cookbooks that show localized strings file structure and Java lookup calls for that language's plural and ordinal categories. When verified day-phrase wording is unavailable, the page labels the gap and renders a neutral, translator-owned structural skeleton instead of guessing the language's word order. Locales with multiple ordinal categories also receive a runnable ordinal cookbook when CLDR supplies a complete set of localized minimal-pair patterns; incomplete source coverage is labeled rather than filled with guessed copy. The page identifies the exact Java Locale construction (either Locale.ROOT or Locale.forLanguageTag(...)), the localized strings filename accepted by LocalizedStringLoader, inherited rule sources, and the formatting-data provenance.
The reference includes every canonical CLDR plural-rule locale and a curated set of widely used region- and script-qualified application profiles. These profiles make inherited behavior explicit without suggesting that every valid IETF BCP 47 tag needs a separate page.
Exact tags for which CLDR supplies distinct rules, such as pt-PT, remain in the canonical CLDR table below. The curated table covers exact tags that inherit their plural rules from another entry.
Common inherited tags without dedicated pages, including en-AU, en-CA, ja-JP, and ko-KR, remain searchable on the website and point to the applicable plural-rule page. Their number formatting, unit wording, and application copy can still differ from the parent locale.
| Locale profile | Tag | Plural rules from |
|---|---|---|
| American English | en-US |
en |
| Arabic (Egypt) | ar-EG |
ar |
| Brazilian Portuguese | pt-BR |
pt |
| British English | en-GB |
en |
| Canadian French | fr-CA |
fr |
| Chinese (China) | zh-CN |
zh |
| Chinese (Hong Kong SAR China) | zh-HK |
zh |
| Chinese (Taiwan) | zh-TW |
zh |
| English (India) | en-IN |
en |
| European Spanish | es-ES |
es |
| Latin American Spanish | es-419 |
es |
| Mexican Spanish | es-MX |
es |
| Serbian (Cyrillic) | sr-Cyrl |
sr |
| Serbian (Latin) | sr-Latn |
sr |
| Simplified Chinese | zh-Hans |
zh |
| Swiss High German | de-CH |
de |
| Traditional Chinese | zh-Hant |
zh |
Lokalized was created by Mark Allen. Development is sponsored by Transmogrify LLC and Revetware LLC.