Stele
JSON-first, type-safe i18n codegen with pluggable per-language emitters.
One JSON catalog in. Idiomatic, fully-typed accessor code out — for every language your product ships in. Think "protobuf for your copy."
A stele is an inscribed stone slab. The Rosetta Stone is a stele: one decree, carved in three scripts. Stele does the same thing for your app's strings — one source of truth, rendered into many languages of code.
The problem
You want translation files as plain JSON, because that's what's easy to hand to translators. But you don't want to write t("home.greeting") all over your code — it's a magic string the compiler can't check, and it's ugly.
Existing tools each solve half of this:
- typesafe-i18n gives you nested typed accessors, but ships a runtime and is web-shaped.
- Paraglide compiles to typed functions, but they're flat and web-shaped.
- SwiftGen / R.swift are typed, but Swift-only.
- TMS platforms (Tolgee, Crowdin, …) manage translations, then hand you stringly-typed native resources.
Nobody takes one JSON catalog and emits type-safe, idiomatic, zero-runtime accessors across many languages. That's Stele.
What you get
Author your copy as plain, translator-friendly JSON:
Placeholders use the {{name}} double-brace convention (whitespace tolerant — {{ name }} works too). Literal single braces in copy are left untouched, so "set it to {0}" is safe.
Run stele generate, and call into it with full autocomplete and compile-time safety — in whichever language you're writing:
TypeScript
const stele = createStele("en");
stele.home.title; // "Sidewalk"
stele.home.greeting({ name: "Brian" }); // typed; { nmae } is a compile error
stele.home.nearby({ count: 3, radius: "200m" });
Swift
let stele = Stele(.en)
stele.home.title // "Sidewalk"
stele.home.greeting(name: "Brian") // idiomatic labeled args
stele.home.nearby(count: 3, radius: "200m")
The accessor is named stele by default — import { stele } and call stele.home.greeting(...), the way you import { z } from "zod" and write z.string(). One config line (binding) renames it to whatever you like (copy, t, …), uniformly across every target.
Same catalog. Same structure. Each target gets the shape a native of that language expects — and in both, a typo'd parameter, a missing argument, a nonexistent key, or a wrong type is a compile error, not a runtime surprise.
Design principles
- JSON-first. The JSON is the source of truth. Generated code is build output you can commit and review in PRs.
- Zero-runtime output. Stele emits plain code — no proxy, no resolver, no runtime dependency. Tree-shakeable, bundler-friendly.
- Hermes-safe by construction. Plural rules are resolved at generate time and baked into a static table. The emitted code never calls
Intl.PluralRules— which matters because React Native's Hermes engine doesn't have it. - Pluggable emitters. A language-neutral, serializable IR is the contract. Adding a language is one emitter against that IR — the core never changes.
How it works
locales/*.json ──▶ parse ──▶ IR (serializable) ──▶ emitter ──▶ generated code
│
├─▶ TypeScript
├─▶ Swift
└─▶ … (your language here)
The IR is the seam. stele ir will print it as JSON, which means emitters can even be written in their own target language (the protoc-plugin model).
Install
The stele binary ships as a native package — no Rust toolchain needed. Add it
and run it as a build step:
The right prebuilt binary for your platform is pulled in automatically (macOS arm64/x64, Linux x64/arm64, Windows x64). Other ways in:
Quickstart
Point stele at a config and generate, then commit the output and import it.
stele.toml:
= "en"
= "locales"
[[]]
= "typescript"
= "src/stele.gen.ts"
# callable = true # emit no-arg leaves as () => "..." thunks
# case = "camel" # output identifier case (see below)
# binding = "stele" # brand name for the API (see below)
[[]]
= "swift"
= "Sources/Stele.swift"
Locale layout
A locale can be one flat file, or split across files and folders — the path becomes the namespace, so you organize copy however your app is organized:
locales/
en/
nav.json → stele.nav.*
walker/
today.json → stele.walker.today.*
schedule.json → stele.walker.schedule.*
es/
nav.json
...
locales/en.json (one file) and locales/en/ (a folder tree) both work, and may
even be mixed; everything for a locale is deep-merged. A key defined twice across
files is an error, never a silent clobber.
Key casing
Author keys in any case — walker_today, walkerToday, walker-today all
parse the same. The case option on a target picks the output identifier case,
applied uniformly to namespaces, leaves, and params:
case |
walker_today → |
param first_name → |
|---|---|---|
camel (default) |
stele.walkerToday |
{ firstName } |
snake |
stele.walker_today |
{ first_name } |
pascal |
stele.WalkerToday |
{ FirstName } |
preserve |
verbatim | verbatim |
If two keys collapse to the same name under the chosen case (dog_count and
dogCount), or a key isn't a valid identifier (2fa), generation fails loudly
rather than emitting broken or silently-clobbered code.
API binding name
The generated API is branded stele by default — the accessor type is Stele,
the factory is createStele, and the react target exports useStele /
SteleProvider. The binding option on a target renames that whole surface with
one word, applied uniformly across every language:
binding |
TypeScript / Swift | React |
|---|---|---|
stele (default) |
createStele / Stele |
useStele, SteleProvider |
copy |
createCopy / Copy |
useCopy, CopyProvider |
t |
createT / T |
useT, TProvider |
It's purely cosmetic — the emitted code is identical apart from those names — so a team can pick the word that reads best at their call sites without any lock-in.
Wire it into your build so it can't drift:
// package.json
"scripts": { "copy:gen": "stele generate" }
A CI check keeps generated output honest: stele generate && git diff --exit-code.
A worked example — input, config, and generated output for both languages — lives in examples/.
The locale store
The core factory (createStele(locale)) is pure — you pass the locale in. For an
app you usually want one active locale that any code can read or change, that
persists across launches, and that re-renders the UI when it flips. That's the
store target: a tiny framework-agnostic module that owns the active locale and
is the single source of truth.
[[]]
= "typescript"
= "src/stele.gen.ts"
[[]]
= "store"
= "src/stele.store.ts"
= "./stele.gen" # import path to the typescript target's output
import {
getStele, getLocale, setLocale, subscribeLocale, // read / write / observe
followDevice, isFollowingDevice, syncDevice, // device / "system" mode
resolveLocale, initLocale, // startup helpers
} from "./stele.store";
getStele().home.greeting({ name }); // accessor bound to the active locale
getLocale(); // "en"
setLocale("es"); // pin Spanish — stops following the device, persists
followDevice(); // back to following the device locale, persists "system"
The store tracks a preference, not just a locale: either "system" (follow
the device) or a pinned Locale. That's the difference between "remember my
choice" and "follow my phone" — keeping them distinct is what avoids the classic
bug where a once-saved locale shadows the device forever.
resolveLocale(tags)maps arbitrary BCP-47 device tags to a supportedLocale(["es-MX","en-US"] → "es"), falling back to the canonical locale.initLocale({ storage, deviceLocales })is the one-call startup: restore the saved preference (or default to"system"), wire the device-locale source, and apply it. Persistence is a pluggable adapter (LocaleStorage) storing"system" | Locale— you hand it AsyncStorage / localStorage, so the store pulls in no platform dependency.syncDevice()re-reads the device locale only while in system mode — wire it to anAppState"active" listener to track the OS language changing live.
// once, before your first render (e.g. behind a splash screen):
await initLocale({
storage: { // ~3 lines you provide
load: () => AsyncStorage.getItem("locale") as Promise<LocalePref | null>,
save: (p) => AsyncStorage.setItem("locale", p),
},
deviceLocales: () => Localization.locales, // expo-localization, navigator.languages, …
});
// follow the device with no persistence at all? just:
await initLocale({ deviceLocales: () => Localization.locales });
React / React Native
Add a react target to bind the store to React via useSyncExternalStore —
hooks only, no Provider to mount. setLocale from anywhere (a component or
not) re-renders every useStele() consumer. Works the same on web React and
React Native (no JSX build step).
[[]]
= "react"
= "src/stele.react.ts"
= "./stele.store" # import path to the store target's output
import { useStele, useLocale, useFollowingDevice } from "./stele.react";
// in a component — re-renders when the locale changes:
const stele = useStele();
const [locale, setLocale] = useLocale();
const onSystem = useFollowingDevice(); // for a "System" radio in settings
return <Text onPress={() => setLocale("es")}>{stele.home.greeting({ name })}</Text>;
Status
Early, but real. Both emitters are verified end-to-end: the generated code compiles, runs, and rejects bad calls at compile time.
- JSON → serializable IR
- TypeScript emitter (nested typed accessors, zero-runtime)
- Swift emitter (idiomatic labeled args, nested structs)
- ICU4X-backed plurals — authoritative CLDR rules baked into per-locale
tables at generate time, validated against the oracle, emitted as pure
lookups (correct
one/few/manyfor Polish, Arabic, Russian, …; no runtimeIntl.PluralRules, so it's Hermes-safe) - Distribution — native binary via npm (
@stelegen/cli) and crates.io (stelegen), cross-compiled for macOS/Linux/Windows by CI on each tag - Multi-file / folder locales (path-as-namespace, deep-merged)
- Locale store (
storetarget) — framework-agnostic single source of truth:getLocale/setLocale/subscribeLocale/resolveLocale/initLocale, observable + pluggable persistence, zero platform deps - Device / "system" mode — store tracks a
"system" | Localepreference (followDevice/isFollowingDevice/syncDevice), so "follow the phone" and "remember my choice" stay distinct - React / React Native bindings (
reacttarget) —useStele/useLocale/useFollowingDevicebound to the store viauseSyncExternalStore, no Provider,setLocalefrom anywhere re-renders consumers -
{{name}}placeholders (double-brace, whitespace tolerant, literal-{}-safe) - Any-input / chosen-output key casing (
caseoption) with collision + invalid-id checks - Configurable API binding name (
bindingoption) —stele.*by default, one word renames the whole surface - More emitters (Kotlin, Go, Rust, Java)
-
$select(gender / arbitrary branching) - Validate
$pluralcoverage against each locale's CLDR category set
License
MIT © 2026 Brian Corbin