Lokalized: make your Java applications sound natural in any language.

Move complex grammar and language rules out of application code and into the hands of your translators.

Proudly powering production systems since 2017.

en.json Localized Strings File (English)
{
  "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

Why Lokalized?

  • 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: LocaleMatcher handles BCP 47 tags, CLDR parent locales, likely scripts, weighted Accept-Language preferences, 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

Non-Goals

  • Date/time, number, percentage, and currency formatting or parsing - the JDK already provides those
  • Collation - the JDK provides locale-aware collators
  • Support Java 8 and below. Lokalized targets Java 9+

License

The 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.

Installation

Maven

<dependency>
  <groupId>com.lokalized</groupId>
  <artifactId>lokalized</artifactId>
  <version>3.0.0</version>
</dependency>

Gradle

dependencies {
  implementation("com.lokalized:lokalized:3.0.0")
}

Direct Download

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.

Do Zero-Dependency Libraries Interest You?

Similarly-flavored commercially-friendly OSS libraries are available.

Getting Started

1. Create Localized Strings Files

Filenames must be IETF BCP 47 language tags, optionally suffixed by .json.

pt-BR.json Localized Strings File (Brazilian Portuguese)
{
  "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."
      }
    ]
  }
}

2. Create a Strings Instance

Choose 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.

3. Ask the Strings Instance for Translations

Raw 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);

Formatting Placeholder Values

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:

en-US.json Localized Strings File (American English)
{
  "You have {{formattedCount}} items.": {
    "translation": "You have {{formattedCount}} {{items}}.",
    "placeholders": {
      "items": {
        "value": "count",
        "translations": {
          "CARDINALITY_ONE": "item",
          "CARDINALITY_OTHER": "items"
        }
      }
    }
  }
}

Usage and expected results

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)
));

4. Ensure Determinism via Tiebreakers

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.

Lokalized fails fast. 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.

5. Respect User Language Preferences

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();

Locale Matching Behavior

The LocaleMatcher implementation is deterministic. It applies these rules in order:

  • Exact language tags, then CLDR-canonical equivalents and legacy aliases.
  • CLDR parent locales before looser language-only matches - for example, en-AU can prefer en-001 before en.
  • Likely-script matching, so zh-TW can match zh-Hant while sr-Latn remains distinct from sr-Cyrl.
  • A compatibility bridge between Norwegian no and Bokmål nb, after exact matches.
  • Configured tiebreakers when multiple loaded locales still share the requested language.
  • Language-range quality weights, including q=0 exclusions.
  • The configured fallback for 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.

Script-aware matching

Script-aware locale matching examples
Request CLDR interpretation Preferred compatible file
zh-TWTraditional Chinesezh-Hant
zh-HKTraditional Chinesezh-Hant
zh-CNSimplified Chinesezh-Hans
zhSimplified Chinese by defaultzh-Hans
srCyrillic Serbian by defaultsr-Cyrl
sr-LatnLatin Serbiansr-Latn
shLegacy tag associated with Latin Serbiansr-Latn

Parent locales and tiebreakers

Parent locale and tiebreaker examples
Request Loaded files Result
en-AUen-001, enen-001
en-AUenen
en-CAen-US, en-GBFirst configured English tiebreaker
fr-BEfr-FR, fr-CAFirst configured French tiebreaker

Norwegian compatibility bridge

Norwegian locale compatibility examples
Request Candidate order
no-NOno-NOnonb-NOnb
nb-NOnb-NOnbnono-NO

Norwegian Nynorsk (nn) is independent and does not participate in this bridge.

The 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.

Loading Localized Strings

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();
  • Use slash-separated classpath names such as com/example/myapp/strings.
  • Use an explicit ClassLoader overload in containers, plugin systems, and test harnesses.
  • Leave exhaustiveClasspathSearch disabled unless a JAR omits package directory entries; enabling it scans every visible filesystem and JAR classpath root.
  • The META-INF/versions directory is reserved from package discovery; use loadFromClasspathResources(...) when an application intentionally needs an exact resource beneath it.
  • The und.json file is the localized strings filename for Locale.ROOT.
  • Input streams use strict UTF-8. Blank or BOM-only files are invalid; use {} for an intentionally empty file.
  • Classpath discovery skips unrelated .json resources with invalid locale filenames and warns; filesystem loading remains strict.

Default loading limits

Default localized strings loading limits
ResourceDefault
One Path or InputStream8 MiB
One Reader8,388,608 UTF-16 code units
JSON nesting64 levels; hard maximum 128
Aggregate input32 MiB
Localized strings files256
Translation nodes100,000
Warnings1,000
Discovery entries100,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);
Explicit resources for constrained classloaders

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
  );

Per-Invocation Options

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.

A More Complex Example

Lokalized is most useful when a sentence must be rewritten across more than one grammatical dimension.

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.

Spanish

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.

English Localized Strings File

en.json Localized Strings File (English)
{
  "{{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."
      }
    ]
  }
}

Spanish Localized Strings File

es.json Localized Strings File (Spanish)
{
  "{{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."
      }
    ]
  }
}

The Rules, Exercised

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);

Cardinality Ranges

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.

fr.json Localized Strings File (French)
{
  "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"
        }
      }
    }
  }
}

Usage and expected results

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 Forms

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.

en.json Localized Strings File (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"
        }
      }
    }
  }
}

Usage and expected results

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

Language forms are typed caller values used by localized strings to choose words or phrases. The supported families follow the README's order below.

Direct API Helpers

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();

Gender

Chooses agreement by grammatical gender. Common gender covers languages that merge masculine and feminine; neuter remains distinct.

GENDER_MASCULINE  // Masculine agreement
GENDER_FEMININE   // Feminine agreement
GENDER_COMMON     // Common masculine-feminine agreement
GENDER_NEUTER     // Neuter or unspecified agreement

Example: English selects He, She, or This person; Spanish may rewrite several agreeing words.

Grammatical Case

Changes nouns or pronouns according to syntactic role. The enum is intentionally broad, though not exhaustive for every language.

CASE_NOMINATIVE     // Citation or subject form
CASE_ACCUSATIVE     // Direct-object form
CASE_GENITIVE       // Possession, source, or partitive-adjacent form
CASE_DATIVE         // Indirect-object or recipient form
CASE_INSTRUMENTAL   // Instrument or accompaniment form
CASE_LOCATIVE       // Location or place form
CASE_PREPOSITIONAL  // Preposition-governed form
CASE_VOCATIVE       // Direct-address form
CASE_ABLATIVE       // Source, motion-away-from, or separation form

Russian: GrammaticalCase.DATIVE can select Ивану in Отправить сообщение Ивану.

Definiteness

Distinguishes definite, indefinite, and construct or bound noun phrases.

DEFINITENESS_DEFINITE    // Definite reference, such as “the book”
DEFINITENESS_INDEFINITE  // Indefinite reference, such as “a book”
DEFINITENESS_CONSTRUCT   // Construct or bound-state form

Arabic: a definite document can select الكتاب, while the indefinite form selects كتابًا.

Classifiers

Selects measure words and counters. The categories are generic semantic buckets; language-specific inventories may need dedicated keys.

CLASSIFIER_GENERAL    // General-purpose classifier
CLASSIFIER_PERSON     // People or human referents
CLASSIFIER_ANIMAL     // Animals or living creatures
CLASSIFIER_LONG_THIN  // Long, thin, or cylindrical objects
CLASSIFIER_FLAT       // Flat, thin, or sheet-like objects
CLASSIFIER_BOUND      // Bound volumes such as books
CLASSIFIER_MACHINE    // Machines, devices, or large equipment
CLASSIFIER_VEHICLE    // Vehicles

Japanese: Classifier.BOUND selects the book counter .

Formality

Selects casual, informal, formal, humble, or honorific register.

FORMALITY_CASUAL     // Casual register
FORMALITY_INFORMAL   // Informal register
FORMALITY_FORMAL     // Formal register
FORMALITY_HUMBLE     // Humble register
FORMALITY_HONORIFIC  // Honorific register

Example: one greeting key can produce Hey, Sam., Hello, Sam., or Greetings, Dr. Smith.

Clusivity

Distinguishes whether first-person plural includes or excludes the addressee.

CLUSIVITY_INCLUSIVE  // “We/us” includes the addressee
CLUSIVITY_EXCLUSIVE  // “We/us” excludes the addressee

Malay: kita includes the listener; kami excludes them.

Animacy

Distinguishes animate and inanimate referents when a language's agreement or case system requires it.

ANIMACY_ANIMATE    // Animate or sentient referent
ANIMACY_INANIMATE  // Inanimate referent

Russian: masculine accusative forms can differ for a brother (брата) and a table (стол).

Plural Cardinality

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   // Locale-specific CLDR zero category
CARDINALITY_ONE    // Locale-specific CLDR one category
CARDINALITY_TWO    // Locale-specific CLDR two category
CARDINALITY_FEW    // Locale-specific CLDR few category
CARDINALITY_MANY   // Locale-specific CLDR many category
CARDINALITY_OTHER  // Catchall category for remaining values

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.

Plural Cardinality Ranges

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.

Phonetics

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       // Begins with a vowel sound
PHONETIC_CONSONANT   // Begins with a typical consonant sound
PHONETIC_H_SILENT    // Begins with a silent H
PHONETIC_H_ASPIRATED // Begins with a pronounced or aspirated H
PHONETIC_S_IMPURE    // Italian s + consonant onset
PHONETIC_Z           // Italian Z or /ts/, /dz/ onset
PHONETIC_GN          // Italian GN onset
PHONETIC_PS          // Italian PS onset
PHONETIC_PN          // Italian PN onset
PHONETIC_X           // Italian X or /ks/ onset
PHONETIC_GLIDE_Y     // Consonantal Y glide
PHONETIC_GLIDE_W     // Consonantal W glide
PHONETIC_STRESSED_A  // Stressed initial A or HA sound
PHONETIC_SOLAR       // Arabic sun-letter behavior
PHONETIC_LUNAR       // Arabic moon-letter behavior
PHONETIC_OTHER       // Fallback for other onset patterns

English: a resolver can select an honor and a gift from the same localized string.

Ordinals

CLDR maps numbers to rank categories. Like cardinalities, category names can cover many values.

ORDINALITY_ZERO   // Locale-specific CLDR zero category
ORDINALITY_ONE    // Locale-specific CLDR one category
ORDINALITY_TWO    // Locale-specific CLDR two category
ORDINALITY_FEW    // Locale-specific CLDR few category
ORDINALITY_MANY   // Locale-specific CLDR many category
ORDINALITY_OTHER  // Catchall category for remaining values

English: 1 and 21 are ONE, 2 is TWO, 3 is FEW, and 12 is OTHER.

Translation Failure Handling

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.

Built-in handlers

  • The returnKey() handler silently returns the key with caller placeholders interpolated.
  • The returnKey(consumer) handler reports the structured failure to an application observer, then returns the key.
  • The throwException() handler throws for missing translations and rethrows resolution failures.

Built-in fallback policies

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().

Translation Diagnostics

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.

Localized Strings File Format

Structure

  • Files are UTF-8 and named with an IETF BCP 47 language tag, optionally followed by .json.
  • A blank or BOM-only file is invalid; use {} for an intentionally empty file.
  • The top level is one JSON object whose keys are translation keys.
  • A value can be a string shorthand or an object with translation, commentary, placeholders, and alternatives.
  • An object must provide a default translation or at least one alternative.
en-GB.json Localized Strings File (British English)
{
  "I am going on vacation.": {
    "commentary": "Shown as an option in the user's status menu.",
    "translation": "I am going on holiday."
  }
}

JSON Schema and Validation Warnings

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());

Commentary

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.

Placeholders

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.

  • Escape an opening delimiter as \\{{name}} in JSON to render the literal text {{name}} instead of resolving it.
  • Each generated definition has one mutually exclusive shape: value plus translations, cardinality range plus translations, or translation with optional expression-selected alternatives.
  • Language-form inputs accept numbers, PluralOperands, the typed language-form enums, or strings resolved through a configured PhoneticResolver. One translations map cannot mix form families.
  • Caller values are the immutable predicate input. Generated values never become expression operands.
  • Only reachable generated placeholders are evaluated.
  • Cycles, excessive depth, missing forms, and failed custom resolution become resolution failures.
  • Caller-supplied text is isolated with Unicode FSI/PDI by default when inserted into right-to-left output. Select BidiIsolation.ALWAYS when right-to-left values can appear in left-to-right translations, or disable isolation for sinks that cannot accept bidi controls.
  • Caller values remain opaque during interpolation, even if their text itself contains placeholder syntax.

Alternatives

A single generated fragment can choose a phrase from its own expressions while the rest of the message resolves independently:

en.json Localized Strings File (English)
{
  "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"
        }
      }
    }
  }
}

Placeholder Scope and Inheritance

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.

Recursive Alternatives

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.

es.json Localized Strings File (Spanish)
{
  "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."
            }
          ]
        }
      }
    ]
  }
}

Expression Language

Fragment alternatives and recursive alternatives use the same bounded expression language:

Expressions support

  • Parenthesized infix expressions with && and ||.
  • Use <, >, <=, and >= for numeric operands; use == and != for numbers and language forms.
  • Numbers, caller variables, and built-in language-form constants.
  • Recursive alternatives and inherited placeholder definitions.

Expressions do not support

  • Unary !.
  • String, Boolean, or explicit null literals.
  • Functions or arbitrary return values.
  • A range operand inside the expression grammar.

Expression grammar

The formal grammar spells out precedence, every built-in language-form constant, numeric syntax, and valid caller-variable names.

View the complete expression grammar
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.

Inspection

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.

Runtime Safety Limits

Localized strings compilation and lookup use immutable TranslationRuntimeLimits. Defaults bound numeric work, expression parsing, recursive placeholder generation, interpolation, and cumulative expansion.

Default and hard runtime safety limits
WorkDefaultHard ceiling
Numeric precision, scale, visible decimals1,0244,096
Compact exponent644,096
Expression characters2,0484,096
Expression tokens256512
Nested expression groups3264
Generated-placeholder depth3264
One interpolated result262,144 UTF-16 code units1,048,576
Cumulative generated expansion per locale attempt1,048,576 UTF-16 code units8,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.

Keying Strategy

Lokalized imposes no naming convention. Natural-language and contextual keys have different tradeoffs, and a project can mix them.

Natural Language Keys

Example: "I read {{bookCount}} books."

Pros

  • Easy to create without a naming registry.
  • Placeholders document themselves in the key.
  • The key is a useful fallback if localized data is missing.

Cons

  • Product context can be lost.
  • Long legal or editorial copy makes poor keys.
  • Wording changes require changing every localized strings file.

Contextual Keys

Examples: Checkout.Title, Checkout.Submit, and Checkout.Cancel.

en.json Localized Strings File (English)
{
  "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"
  }
}

Pros

  • Targets a specific surface or component.
  • Works for large or frequently revised copy.
  • Translation wording can change without code changes.

Cons

  • Every key needs a deliberate name and record.
  • Placeholders require explicit translator documentation.
  • A missing entry can expose the contextual key to users.

Or - Mix Both

Natural-language keys can cover ordinary product copy while contextual keys handle legal text and surfaces where wording or context changes independently.

Comparing Localization Formats

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.

A Common Baseline: Plural Selection

Classic ICU MessageFormat expresses a plural message compactly:

{bookCount, plural, one {I read # book.} other {I read # books.}}

Equivalent Lokalized structure

en.json Localized Strings File (English)
{
  "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.

Where Lokalized Goes Further Out of the Box

ConcernLokalizedOther 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.

Where Lokalized's Approach Shines

  • 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.

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.