English
He was one of the 3 best baseball players.
She was the best baseball player.
This person was one of the 3 best baseball players.
Move complex grammar and language rules out of application code and into the hands of your translators.
Proudly powering production systems since 2017.
{ "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." } ] } }
// Your Java code String message = strings.get( "I read {{bookCount}} books.", Map.of("bookCount", 3) );
bookCount = 3“I read 3 books.”OTHER
bookCount = 1“I read 1 book.”ONE
bookCount = 0“I didn't read any books.”alternative
LocaleMatcher handles BCP 47 tags, CLDR parent locales, likely scripts, weighted Accept-Language preferences, and explicit tiebreakers deterministically; see the matching orderThe Lokalized library is open source under the commercially friendly Apache License 2.0.
The project also incorporates MIT-licensed minimal-json source and flag artwork, plus generated language data derived from Unicode CLDR under Unicode License v3.
See the Licensing page for full terms, attribution, and third-party notices.
<dependency> <groupId>com.lokalized</groupId> <artifactId>lokalized</artifactId> <version>3.0.0</version> </dependency>
dependencies { implementation("com.lokalized:lokalized:3.0.0") }
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.
Similarly-flavored commercially-friendly OSS libraries are available.
Filenames must be IETF BCP 47 language tags, optionally suffixed by .json.
{ "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." } ] } }
Strings InstanceChoose a fallback locale, provide localized strings, and resolve the caller's locale. A single immutable instance can serve concurrent and multitenant applications.
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();
Curious about failed lookup handling or immutable reloads? Check out the Cookbook.
Strings Instance for TranslationsRaw values drive the language rule. Lokalized selects the matching form or alternative and then interpolates the supplied values.
String message = strings.get("I read {{bookCount}} books.", Map.of("bookCount", 3)); assertEquals("Li 3 livros.", message); message = strings.get("I read {{bookCount}} books.", Map.of("bookCount", 1)); assertEquals("Li 1 livro.", message); message = strings.get("I read {{bookCount}} books.", Map.of("bookCount", 0)); assertEquals("Não li nenhum livro.", message);
Lokalized selects translations and interpolates values; it does not format dates, times, numbers, percentages, or currencies. Use NumberFormat, DateTimeFormatter, and other JDK formatters.
When one number affects language selection and also needs formatted display, pass the raw and formatted values separately:
{ "You have {{formattedCount}} items.": { "translation": "You have {{formattedCount}} {{items}}.", "placeholders": { "items": { "value": "count", "translations": { "CARDINALITY_ONE": "item", "CARDINALITY_OTHER": "items" } } } } }
int count = 12_345; Locale locale = Locale.forLanguageTag("en-US"); // Produces "You have 12,345 items." String message = strings.get("You have {{formattedCount}} items.", Map.of( "count", count, "formattedCount", NumberFormat.getIntegerInstance(locale).format(count) ));
If both en-US and en-GB are loaded and a caller requests Canadian English (en-CA), neither an exact match nor a CLDR parent locale is present. Both files are compatible en-Latn candidates, so the configured order decides which translation wins:
Strings strings = Strings.withFallbackLocale(FALLBACK_LOCALE) .localizedStringSupplier(() -> LocalizedStringLoader .loadFromFilesystem(Paths.get("my-directory"))) .localeSupplier(matcher -> matcher.bestMatchFor( MyWebContext.getHttpServletRequest().getLocale())) .tiebreakerLocalesByLanguageCode(Map.of( // Prefer US English to British English when both are equally good matches "en", List.of( Locale.forLanguageTag("en-US"), Locale.forLanguageTag("en-GB") ) )) .build();
The tiebreakerLocalesByLanguageCode(...) map keys are primary BCP 47 language subtags. Each list must contain every loaded locale for that language exactly once; canonical aliases such as he and iw cannot be configured as separate keys.
Strings during startup, build() throws IllegalArgumentException unless each ambiguous language has a tiebreaker list containing every loaded locale exactly once.An Accept-Language value such as en-GB;q=1.0,en;q=0.75,fr-FR;q=0.25 describes a weighted preference list. Combine repeated field lines in received order, then pass the value to bestMatchForAcceptLanguage(...) to compare it with the loaded locales:
Strings strings = Strings.withFallbackLocale(FALLBACK_LOCALE) .localizedStringSupplier(() -> LocalizedStringLoader .loadFromFilesystem(Paths.get("my-directory"))) .localeSupplier(matcher -> matcher.bestMatchForAcceptLanguage( MyWebContext.getCombinedAcceptLanguageHeader())) .build();
The LocaleMatcher implementation is deterministic. It applies these rules in order:
en-AU can prefer en-001 before en.zh-TW can match zh-Hant while sr-Latn remains distinct from sr-Cyrl.no and Bokmål nb, after exact matches.q=0 exclusions.Locale.ROOT, und, wildcards, empty preferences, or unmatched requests.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.
bestMatchForAcceptLanguage(...) method bounds raw input before parsing and falls back for unusable values without truncating preferences. The parsed-list APIs remain strict and accept at most 32 ranges.Use the filesystem during development and a namespaced classpath package in packaged applications. Both loaders scan only the requested directory or package; they do not recurse into children.
// Load editable localized strings from a filesystem directory Strings filesystemStrings = Strings.withFallbackLocale(Locale.ENGLISH) .localizedStringSupplier(() -> LocalizedStringLoader .loadFromFilesystem(Paths.get("strings"))) .localeSupplier(matcher -> matcher.bestMatchFor(Locale.US)) .build(); // Load packaged localized strings from a classpath package Strings classpathStrings = Strings.withFallbackLocale(Locale.ENGLISH) .localizedStringSupplier(() -> LocalizedStringLoader .loadFromClasspath("com/example/myapp/strings")) .localeSupplier(matcher -> matcher.bestMatchFor(Locale.US)) .build();
com/example/myapp/strings.ClassLoader overload in containers, plugin systems, and test harnesses.exhaustiveClasspathSearch disabled unless a JAR omits package directory entries; enabling it scans every visible filesystem and JAR classpath root.META-INF/versions directory is reserved from package discovery; use loadFromClasspathResources(...) when an application intentionally needs an exact resource beneath it.und.json file is the localized strings filename for Locale.ROOT.{} for an intentionally empty file..json resources with invalid locale filenames and warns; filesystem loading remains strict.| Resource | Default |
|---|---|
One Path or InputStream | 8 MiB |
One Reader | 8,388,608 UTF-16 code units |
| JSON nesting | 64 levels; hard maximum 128 |
| Aggregate input | 32 MiB |
| Localized strings files | 256 |
| Translation nodes | 100,000 |
| Warnings | 1,000 |
| Discovery entries | 100,000; hard maximum 1,000,000 |
// Customize the resource limits enforced while parsing localized strings 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) .build(); Map<Locale, Set<LocalizedString>> localizedStringsByLocale = LocalizedStringLoader.loadFromClasspath("strings", limits);
Some plugin classloaders can open known resources but cannot enumerate their package. Map locales to exact resource paths in those environments:
Explicit locale-to-resource maps and single-resource parsing enumerate no candidates and therefore do not consume the discovery-entry budget.
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 );
Request-scoped locale suppliers are convenient for web applications. Async work, tests, batch jobs, and administrative tools can override one call with TranslationOptions:
// With a matching fr-CA translation, this might return "J’ai lu 1 livre." String message = strings.get( "I read {{bookCount}} books.", Map.of("bookCount", 1), TranslationOptions.forLocale(Locale.forLanguageTag("fr-CA")) );
Per-call options can provide a locale or language ranges, bidi isolation behavior, a fallback policy, or a failure handler. Using BidiIsolation.ALWAYS protects caller-supplied right-to-left text inside left-to-right translations as well as the inverse case; RTL_LOCALES remains the default. The same matching and tiebreaker rules still apply.
Lokalized is most useful when a sentence must be rewritten across more than one grammatical dimension.
He was one of the 3 best baseball players.
She was the best baseball player.
This person was one of the 3 best baseball players.
Fue uno de los 3 mejores jugadores de béisbol.
Ella era la mejor jugadora de béisbol.
Esta persona estaba entre las 3 personas que mejor jugaban al béisbol.
{ "{{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." } ] } }
{ "{{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." } ] } }
Application code supplies typed facts. The selected locale owns agreement and complete-phrase rewrites.
String message = strings.get( "{{heOrShe}} was one of the {{groupSize}} best baseball players.", Map.of("heOrShe", Gender.FEMININE, "groupSize", 3) ); assertEquals("Fue una de las 3 mejores jugadoras de 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);
A range such as 1-3 hours has a locale-specific form derived from its start and end cardinalities. CLDR's applicable English span mappings generally select OTHER; French can select ONE or OTHER. Unmapped pairs follow the library's documented end-category fallback.
{ "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" } } } } }
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); 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);
Range rules are CLDR data, not a simple “use the ending number” heuristic. The language reference exposes each locale's mappings and provenance.
Ordinal categories express ranks such as 1st, 2nd, and 3rd. English has four CLDR categories; Spanish has only ORDINALITY_OTHER, but application-specific alternatives can still model a first birthday or quinceañera.
{ "{{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" } } } } }
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); 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);
Language forms are typed caller values used by localized strings to choose words or phrases. The supported families follow the README's order below.
Applications normally pass raw values to Strings, but the same CLDR-backed rules are available directly. Visible decimals are significant only when the input preserves them: an ordinary double value of 1.0 is normalized like 1, while BigDecimal("1.0") or explicit PluralOperands retains the displayed scale.
Locale english = Locale.forLanguageTag("en"); Cardinality cardinality = Cardinality.forNumber(1, english); assertEquals(Cardinality.ONE, cardinality); // BigDecimal preserves the visible decimal in "1.0" cardinality = Cardinality.forNumber(new BigDecimal("1.0"), english); assertEquals(Cardinality.OTHER, cardinality); // The same display intent can be supplied explicitly with PluralOperands PluralOperands operands = PluralOperands.forNumber(1) .visibleDecimalPlaces(1) .build(); cardinality = Cardinality.forOperands(operands, english); assertEquals(Cardinality.OTHER, cardinality); Ordinality ordinality = Ordinality.forNumber(21, english); assertEquals(Ordinality.ONE, ordinality); Locale spanish = Locale.forLanguageTag("es"); PhoneticResolver spanishResolver = (term, locale) -> { if (!"es".equals(locale.getLanguage())) return Phonetic.OTHER; String normalized = term.toLowerCase(Locale.ROOT); return Set.of("acta", "arma", "hacha").contains(normalized) ? Phonetic.STRESSED_A : Phonetic.OTHER; }; Strings strings = Strings.withFallbackLocale(spanish) .localizedStringSupplier(() -> LocalizedStringLoader.loadFromClasspath("strings")) .phoneticResolver(spanishResolver) .localeSupplier(matcher -> spanish) .build();
Chooses agreement by grammatical gender. Common gender covers languages that merge masculine and feminine; neuter remains distinct.
GENDER_MASCULINE
GENDER_FEMININE
GENDER_COMMON
GENDER_NEUTER
Example: English selects He, She, or This person; Spanish may rewrite several agreeing words.
Changes nouns or pronouns according to syntactic role. The enum is intentionally broad, though not exhaustive for every language.
CASE_NOMINATIVE
CASE_ACCUSATIVE
CASE_GENITIVE
CASE_DATIVE
CASE_INSTRUMENTAL
CASE_LOCATIVE
CASE_PREPOSITIONAL
CASE_VOCATIVE
CASE_ABLATIVE
Russian: GrammaticalCase.DATIVE can select Ивану in Отправить сообщение Ивану.
Distinguishes definite, indefinite, and construct or bound noun phrases.
DEFINITENESS_DEFINITE
DEFINITENESS_INDEFINITE
DEFINITENESS_CONSTRUCT
Arabic: a definite document can select الكتاب, while the indefinite form selects كتابًا.
Selects measure words and counters. The categories are generic semantic buckets; language-specific inventories may need dedicated keys.
CLASSIFIER_GENERAL
CLASSIFIER_PERSON
CLASSIFIER_ANIMAL
CLASSIFIER_LONG_THIN
CLASSIFIER_FLAT
CLASSIFIER_BOUND
CLASSIFIER_MACHINE
CLASSIFIER_VEHICLE
Japanese: Classifier.BOUND selects the book counter 冊.
Selects casual, informal, formal, humble, or honorific register.
FORMALITY_CASUAL
FORMALITY_INFORMAL
FORMALITY_FORMAL
FORMALITY_HUMBLE
FORMALITY_HONORIFIC
Example: one greeting key can produce Hey, Sam., Hello, Sam., or Greetings, Dr. Smith.
Distinguishes whether first-person plural includes or excludes the addressee.
CLUSIVITY_INCLUSIVE
CLUSIVITY_EXCLUSIVE
Malay: kita includes the listener; kami excludes them.
Distinguishes animate and inanimate referents when a language's agreement or case system requires it.
ANIMACY_ANIMATE
ANIMACY_INANIMATE
Russian: masculine accusative forms can differ for a brother (брата) and a table (стол).
CLDR maps numeric operands - not just integers - to locale-specific plural categories. Category names do not necessarily mean only the number they name.
CARDINALITY_ZERO
CARDINALITY_ONE
CARDINALITY_TWO
CARDINALITY_FEW
CARDINALITY_MANY
CARDINALITY_OTHER
Examples: Japanese uses OTHER for every number; English uses ONE/OTHER; Russian also uses FEW and MANY. Preserved visible decimals mean 1 and BigDecimal("1.0") can resolve differently.
Combines the start and end categories through CLDR range mappings.
Cardinality.forRange(start, end, locale)
Examples: Every CLDR-listed English range pair resolves to OTHER, but the unlisted ONE-ONE pair follows Lokalized's end-category fallback and resolves to ONE. French ONE-ONE resolves to ONE; Latvian has distinct mappings across ZERO, ONE, and OTHER.
Allows an application-provided PhoneticResolver to choose forms from the sound of a term, such as English a/an or Spanish stressed a.
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
English: a resolver can select an honor and a gift from the same localized string.
CLDR maps numbers to rank categories. Like cardinalities, category names can cover many values.
ORDINALITY_ZERO
ORDINALITY_ONE
ORDINALITY_TWO
ORDINALITY_FEW
ORDINALITY_MANY
ORDINALITY_OTHER
English: 1 and 21 are ONE, 2 is TWO, 3 is FEW, and 12 is OTHER.
Locale fallback and final failure handling are separate decisions. A TranslationFallbackPolicy decides whether to try another locale; only after fallback stops does a TranslationFailureHandler decide what to return or throw.
returnKey() handler silently returns the key with caller placeholders interpolated.returnKey(consumer) handler reports the structured failure to an application observer, then returns the key.throwException() handler throws for missing translations and rethrows resolution failures.fallbackOnMissingTranslationOrNoMatchingAlternative() policy is the safe default.fallbackOnAnyFailure() policy also falls back after resolution failures.neverFallback() policy stops after the first failed locale.Strings strings = Strings.withFallbackLocale(Locale.ENGLISH) .localizedStringSupplier(() -> LocalizedStringLoader .loadFromClasspath("strings")) .localeSupplier(matcher -> matcher.bestMatchFor(Locale.US)) .translationFailureHandler(TranslationFailureHandler.returnKey(failure -> exampleMetrics.increment("lokalized.translation." + failure.getReason()))) .build();
Failure reasons distinguish MISSING_TRANSLATION, NO_MATCHING_ALTERNATIVE, and RESOLUTION_FAILURE. The TranslationFailure.getMessage() method contains only the key, lookup locale, reason, and attempted locales; it omits caller placeholder values and runtime-cause messages. The cause remains available separately through getCause().
Most callers use get(...). Applications that need telemetry, audit trails, or negotiation details can request a structured TranslationResult:
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();
Results expose status, resolved locale, attempted locales, whether fallback occurred, the locale-match result, and any failure reason. Use localeMatchSupplier(...) when the original language ranges and match type must survive into diagnostics.
.json.{} for an intentionally empty file.translation, commentary, placeholders, and alternatives.translation or at least one alternative.{ "I am going on vacation.": { "commentary": "Shown as an option in the user's status menu.", "translation": "I am going on holiday." } }
The packaged JSON Schema validates file structure, placeholder shapes, and known language-form names. The loader separately parses expressions and produces structured warnings for incomplete locale-specific form maps. Warnings are silently ignored unless the application supplies a handler.
// Default: load successfully without emitting warning callbacks Map<Locale, Set<LocalizedString>> strings = LocalizedStringLoader.loadFromClasspath("strings"); // Receive structured warnings directly for CI or application telemetry List<LocalizedStringWarning> warnings = new ArrayList<>(); LocalizedStringLoader.loadFromClasspath("strings", warning -> { warnings.add(warning); }); // Fail fast when a localized strings file is incomplete LocalizedStringLoader.loadFromClasspath( "strings", LocalizedStringWarningHandler.throwException());
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.
Caller placeholders use {{name}}. Generated placeholders can select by one language form, by a cardinality range, or by ordered expressions. Names are Unicode-aware, built-in language-form constants are reserved, and generated definitions may depend on other generated values.
\\{{name}} in JSON to render the literal text {{name}} instead of resolving it.value plus translations, cardinality range plus translations, or translation with optional expression-selected alternatives.PluralOperands, the typed language-form enums, or strings resolved through a configured PhoneticResolver. One translations map cannot mix form families.BidiIsolation.ALWAYS when right-to-left values can appear in left-to-right translations, or disable isolation for sinks that cannot accept bidi controls.A single generated fragment can choose a phrase from its own expressions while the rest of the message resolves independently:
{ "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" } } } } }
Root definitions and definitions from each selected alternative are inherited by descendants. The nearest same-named definition replaces its ancestor as a complete unit. Raw caller values keep precedence for predicates.
A selected alternative can refine itself with additional rules. Alternatives are evaluated in list order and stop at the first match. Nested alternatives run before their branch's default translation, and a selected branch never falls through to a later sibling.
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 common or neuter gender.
A flat ordered list using && would be equivalent. Recursion adds hierarchy, branch-local defaults, and inherited definitions rather than additional Boolean power. This complete Spanish entry expects 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." } ] } } ] } }
Fragment alternatives and recursive alternatives use the same bounded expression language:
&& and ||.<, >, <=, and >= for numeric operands; use == and != for numbers and language forms.!.null literals.The formal grammar spells out precedence, every built-in language-form constant, numeric syntax, and valid caller-variable names.
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.
Built-in language-form constants are reserved and cannot be used as placeholder names.
The Strings API exposes read-only helpers for audits, coverage tools, and build checks:
Set<Locale> supportedLocales = strings.getSupportedLocales(); Set<String> englishKeys = strings.getKeysForLocale(Locale.forLanguageTag("en")); Set<String> missingFrenchKeys = strings.getMissingKeys( Locale.forLanguageTag("en"), Locale.forLanguageTag("fr") );
The getKeysForLocale(...) and getMissingKeys(...) methods are strict: unsupported locales throw. Probe getSupportedLocales() first when availability is uncertain.
Localized strings compilation and lookup use immutable TranslationRuntimeLimits. Defaults bound numeric work, expression parsing, recursive placeholder generation, interpolation, and cumulative expansion.
| Work | Default | Hard ceiling |
|---|---|---|
| Numeric precision, scale, visible decimals | 1,024 | 4,096 |
| Compact exponent | 64 | 4,096 |
| Expression characters | 2,048 | 4,096 |
| Expression tokens | 256 | 512 |
| Nested expression groups | 32 | 64 |
| Generated-placeholder depth | 32 | 64 |
| One interpolated result | 262,144 UTF-16 code units | 1,048,576 |
| Cumulative generated expansion per locale attempt | 1,048,576 UTF-16 code units | 8,388,608 |
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();
The loader checks expression and numeric-literal hard ceilings before an application chooses a runtime policy. Building Strings then compiles the loaded data and enforces the configured limits. Locale fallback starts a fresh generated-expansion budget for each candidate.
Lokalized imposes no naming convention. Natural-language and contextual keys have different tradeoffs, and a project can mix them.
Example: "I read {{bookCount}} books."
Examples: 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" } }
Natural-language keys can cover ordinary product copy while contextual keys handle legal text and surfaces where wording or context changes independently.
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.}}
{ "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. |
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.