Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

315 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Lokalized

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.

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

  • 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

Do Zero-Dependency Libraries Interest You?

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

License

Apache 2.0

Maven Installation

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

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.

Getting Started

We'll start with hands-on examples to illustrate key features.

1. Create Localized Strings Files

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."
      }
    ]
  }  
}

2. Create a Strings Instance

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

3. Ask Strings Instance For Translations

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

Formatting Placeholder Values

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

4. Ensure Determinism via Tiebreakers

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.

5. Respect User Language Preferences

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

Locale Matching Behavior

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-AU can prefer a configured en-001 file before en
  • Matching is script-aware when CLDR likely-subtag data can infer a script. For example, zh-TW can match zh-Hant, and sr-Latn is distinct from sr-Cyrl
  • The Norwegian macrolanguage tag no and Norwegian Bokmål tag nb bridge 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=0 range 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.

Script-aware matching

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

Parent locales and tiebreakers

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

Norwegian compatibility bridge

Request Candidate order
no-NO no-NOnonb-NOnb
nb-NO nb-NOnbnono-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.

Loading Localized Strings

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

Per-Invocation Options

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.

Runtime Safety Limits

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.

A More Complex Example

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 Localized Strings File

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.

Spanish Localized Strings File

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."        
      }
    ]
  }
}

The Rules, Exercised

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

Cardinality Ranges

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 Localized Strings File

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 Localized Strings File

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"
        }
      }
    }
  }
}

Cardinality Ranges, Exercised

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

Ordinal Forms

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 intersection
  • She is my 2nd cousin
  • I finished the race in 3rd place

Let's look at an example related to birthdays.

English Localized Strings File

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"
        }
      }
    }
  }
}

Spanish Localized Strings File

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."        
      }
    ]
  }
}

Ordinals, Exercised

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

Language Forms

Gender

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

Grammatical case rules determine how a noun or pronoun changes according to its syntactic role.

Lokalized supports these values:

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.

Example

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

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.

Example

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

Classifiers

Classifier rules select the measure word or counter associated with a noun.

Lokalized supports these values:

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.

Example

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

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.

Example

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

Clusivity rules distinguish between inclusive and exclusive first-person plurals.

Lokalized supports these values:

Lokalized provides a Clusivity type which enumerates supported clusivity values.

Example

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

Animacy rules distinguish between animate and inanimate referents.

Lokalized supports these values:

Lokalized provides an Animacy type which enumerates supported animacy values.

Example

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

Plural Cardinality

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

Japanese

English

Russian

  • CARDINALITY_ONE: Matches 1, 21, 31, ... (e.g. 1 рубль or 51 рубль)
  • CARDINALITY_FEW: Matches 2-4, 22-24, 32-34, ... (e.g. 2 рубля or 53 рубля)
  • CARDINALITY_MANY: Matches 0, 5-20, 25-30, 45-50, ... (e.g. 5 рублей or 17 рублей)
  • 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(...).

Plural Cardinality Ranges

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.

English

French

Latvian

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

Phonetics

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:

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.

English Example

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

Spanish Example (Stressed A)

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

Ordinals

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.

Spanish

  • ORDINALITY_OTHER: Matches every number (CLDR defines no category-dependent ordinal variation for Spanish)

English

Italian

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.

CLDR Data

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 test

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

Translation Failure Handling

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:

Built-in fallback policies are:

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.

Translation Diagnostics

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.

Localized Strings File Format

Structure

  • Each localized strings file must be UTF-8 encoded and named according to the appropriate IETF BCP 47 language tag, such as en or zh-TW (an optional .json suffix like en.json is 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.

JSON Schema

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.

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.

{
  "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."
  }
}

Placeholders

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.

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

Alternatives

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.

Placeholder Scope and Inheritance

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.

Recursive Alternatives

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 translation is present in any attempted candidate locale, failure handlers receive TranslationFailureReason.NO_MATCHING_ALTERNATIVE

Expression Language

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

What Expressions Currently Support

  • 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

What Expressions Do Not Currently Support

  • The unary ! operator
  • String literals, Boolean literals, or explicit null operands
  • Textual equality between two raw CharSequence placeholder values; compare phonetic input with PHONETIC_*
  • Functions or expressions that return arbitrary values
  • A cardinality range construct (to be added in a future release)

Inspection

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.

Keying Strategy

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.

Natural Language Keys

For example: "I read {{bookCount}} books."

Pros

  • 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

Cons

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

Contextual Keys

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"
  }
}

Pros

  • 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

Cons

  • 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

Or - Mix Both!

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.

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

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.

Where Lokalized Goes Further Out of the Box

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.

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.

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.

Language Reference

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.

Curated exact-tag profiles

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

Canonical CLDR plural-rule locales

Language Tag
Afrikaans af
Akan ak
Albanian (Shqip) sq
Amharic (አማርኛ) am
Anii blo
Arabic (العربية) ar
Aragonese an
Armenian (հայերէն) hy
Assamese (অসমীয়া) as
Asturian (asturianu) ast
Asu (asa) asa
Azeri (Azerbaijani) az
Baluchi bal
Bambara (Bamanankan) bm
Bangla (বাংলা) bn
Basque (euskara) eu
Belarusian (беларускі) be
Bemba (ChiBemba) bem
Bena (Bəna) bez
Bhojpuri bho
Bodo (बर') brx
Bosnian (босански) bs
Breton (brezhoneg) br
Bulgarian (български) bg
Burmese my
Cantonese (廣東話) yue
Catalan (català) ca
Cebuano ceb
Central Atlas Tamazight (Tamaziġt) tzm
Central Kurdish (کوردیی ناوەندی) ckb
Chechen (Нохчийн мотт) ce
Cherokee (ᏣᎳᎩ ᎦᏬᏂᎯᏍᏗ) chr
Chiga (Rukiga) cgg
Chuvash cv
Colognian (Kölsch Platt) ksh
Cornish (Kernowek) kw
Croatian (hrvatski) hr
Czech (čeština) cs
Danish (Dansk) da
Divehi (dhivehi) dv
Dogri doi
Dutch (Nederlands) nl
Dzongkha (རྫོང་ཁ་) dz
English en
Esperanto eo
Estonian (Eesti) et
European Portuguese pt-PT
Ewe (Èʋe) ee
Faroese (føroyskt) fo
Filipino (Wikang Filipino) fil
Finnish (suomi) fi
French (français) fr
Friulian (Furlan) fur
Fulah (Fulfulde) ff
Galician (galego) gl
Ganda (Oluganda) lg
Georgian (ქართული) ka
German (Deutsch) de
Greek (Ελληνικά) el
Greenlandic (Kalaallisut) kl
Gujarati (ગુજરાતી) gu
Gun (Fon gbè) guw
Hausa (هَرْشَن هَوْسَ‎) ha
Hawaiian (Ōlelo Hawaiʻi) haw
Hebrew (עברית) he
Hindi (हिंदी) hi
Hmong Njua hnj
Hungarian (magyar) hu
Icelandic (íslenska) is
Ido io
Igbo (Asụsụ Igbo) ig
Inari Sami (anarâškielâ) smn
Indonesian (Bahasa Indonesia) id
Interlingua ia
Interlingue ie
Inuktitut (ᐃᓄᒃᑎᑐᑦ) iu
Irish (Gaeilge) ga
Italian (italiano) it
Japanese (日本語) ja
Javanese (basa Jawa) jv
Jju (Kaje) kaj
Kabuverdianu (Kriolu) kea
Kabyle (Taqbaylit) kab
Kako kkj
Kannada (ಕನ್ನಡ) kn
Kashmiri (कॉशुर) ks
Kazakh (қазақ тілі) kk
Khmer (ភាសាខ្មែរ) km
Kirghiz (кыргызча) ky
Konkani kok
Konkani (Latin) kok-Latn
Korean (한국어) ko
Koyraboro Senni (koyra-boro senn-i) ses
Kurdish (کوردی) ku
Ladin lld
Lakota (Lakȟótiyapi) lkt
Langi (Kilaangi) lag
Lao (ພາສາລາວ) lo
Latvian (Latviešu) lv
Ligurian lij
Lingala (Lingála) ln
Lithuanian (Lietuvių) lt
Lojban (la .lojban.) jbo
Lower Sorbian (Dolnoserbski) dsb
Lule Sami (julevsámegiella) smj
Luxembourgish (Lëtzebuergesch) lb
Macedonian (македонски) mk
Machame jmc
Makonde (Chi(ni)makonde) kde
Malagasy (Fiteny Malagasy) mg
Malay (بهاس ملايو‎) ms
Malayalam (മലയാളം) ml
Maltese (Malti) mt
Mandarin Chinese (中文) zh
Manx (Gaelg) gv
Marathi (मराठी) mr
Masai (ɔl Maa) mas
Metaʼ mgo
Mongolian (монгол хэл) mn
N’Ko nqo
Nahuatl (Nāhuatl) nah
Najdi Arabic (اللهجة النجدية) ars
Nama (Khoekhoegowab) naq
Nepali (नेपाली भाषा) ne
Ngiemboon (Ngyɛmbɔɔŋ) nnh
Ngomba (Nda’a) jgo
Nigerian Pidgin pcm
North Ndebele (isiNdebele) nd
Northern Sami (davvisámegiella) se
Northern Sotho (Sesotho sa Leboa) nso
Norwegian (norsk) no
Norwegian Bokmål (bokmål) nb
Norwegian Nynorsk (nynorsk) nn
Nyanja (Chinyanja) ny
Nyankole (Runyankore) nyn
Odia (ଓଡ଼ିଆ) or
Oromo (Afaan Oromoo) om
Osage osa
Ossetian (Ирон æвзаг) os
Papiamento (Papiamentu) pap
Persian (فارسی) fa
Polish (polski) pl
Portuguese (português) pt
Prussian (Prūsiskan) prg
Punjabi (پنجابی) pa
Pushto (پښتو) ps
Romanian (română) ro
Romansh (rumàntsch) rm
Rombo (Kirombo) rof
Russian (русский) ru
Rwa (West Chaga) rwk
Saho (ሳሆኛ) ssy
Sakha (Саха тыла) sah
Samburu (Sampur) saq
Sami (Saami) smi
Samogitian sgs
Sango (yângâ tî sängö) sg
Santali sat
Sardinian sc
Scottish Gaelic (Gàidhlig) gd
Sena (xisena) seh
Serbian (Српски) sr
Shambala (kishambaa) ksb
Shona (chiShona) sn
Sichuan Yi (ꆈꌠ꒿) ii
Sicilian scn
Sindhi sd
Sinhalese (සිංහල) si
Skolt Sami (sääʹmǩiõll) sms
Slovak (Slovenčina) sk
Slovenian (Slovenščina) sl
Soga (Lusoga) xog
Somali (اف سومالى‎) so
South Ndebele (isiNdebele) nr
Southern Kurdish (کوردی خوارگ) sdh
Southern Sami (Åarjelsaemien gïele) sma
Southern Sotho (Sesotho) st
Spanish (español) es
Sundanese su
Swahili (Kiswahili) sw
Swampy Cree csw
Swati (SiSwati) ss
Swedish (svenska) sv
Swiss German (Schwiizerdütsch) gsw
Syriac (Leššānā Suryāyā) syr
Tachelhit (Tašlḥiyt) shi
Tamil (Tamiḻ) ta
Telugu (తెలుగు) te
Teso (Ateso) teo
Thai (ไทย) th
Tibetan (བོད་སྐད་) bo
Tigre (ትግራይት) tig
Tigrinya (ትግርኛ) ti
Tok Pisin tpi
Tongan (lea faka-Tonga) to
Tsonga (Xitsonga) ts
Tswana (Setswana) tn
Turkish (Türkçe) tr
Turkmen (Türkmençe) tk
Tyap (Katab) kcg
Uighur (ئۇيغۇر تىلى) ug
Ukrainian (українська) uk
Undetermined (CLDR root) und
Upper Sorbian (hornjoserbšćina) hsb
Urdu (اُردُو) ur
Uzbek (ўзбек тили) uz
Venda (Tshivenḓa) ve
Venetian vec
Vietnamese (Tiếng Việt) vi
Volapük vo
Vunjo (Wuunjo) vun
Walloon (Walon) wa
Walser (Walscher) wae
Welsh (Cymraeg) cy
Western Frisian (Frysk) fy
Wolof wo
Xhosa (isiXhosa) xh
Yiddish (יידיש) yi
Yoruba (Èdè Yorùbá) yo
Zulu (isiZulu) zu

About

Lokalized was created by Mark Allen. Development is sponsored by Transmogrify LLC and Revetware LLC.

Releases

Packages

Used by

Contributors

Languages