A tiny, thread-safe i18n key–value store with hot language switching and a simple event model.
language-wizard is a minimalistic helper for applications that need a simple dictionary-based i18n. It stores the
current ISO language code and an in-memory map of translation strings, lets you switch the active language atomically,
and exposes a small event mechanism so background workers can react to changes or closure. The internal state is guarded
by a sync.RWMutex for concurrent access.
- Simple key–value dictionary for translations.
- Hot language switching with atomic swap of the dictionary.
- Thread-safe reads/writes guarded by a RWMutex.
- Defensive copy when exposing the map to callers.
- Blocking wait for language changes or closure via a tiny event model.
- Pluggable logger for missing keys.
go get github.com/voluminor/language_wizardOr vendor/copy the language_wizard package into your project’s source tree.
package main
import (
"fmt"
"log"
"github.com/voluminor/language_wizard"
)
func main() {
obj, err := language_wizard.New("en", map[string]string{
"hi": "Hello",
})
if err != nil {
log.Fatal(err)
}
// Lookup with default
fmt.Println(obj.Get("hi", "DEF")) // "Hello"
fmt.Println(obj.Get("bye", "Bye")) // "Bye" (and logs "undef: bye")
// Optional: hook a logger for misses
obj.SetLog(func(s string) {
log.Printf("language-wizard: %s", s)
})
// Switch language at runtime
_ = obj.SetLanguage("de", map[string]string{
"hi": "Hallo",
})
fmt.Println(obj.CurrentLanguage()) // "de"
fmt.Println(obj.Get("hi", "DEF")) // "Hallo"
}New validates that the ISO language is not empty and the words map is non-nil and non-empty. The initial map is
defensively copied.
Get returns a default when the key is empty or missing and logs undefined keys via the configured logger.
obj, err := language_wizard.New(isoLanguage string, words map[string]string)- Fails with
ErrNilIsoLangifisoLanguageis empty. - Fails with
ErrNilWordsifwordsisnilor empty. - On success, stores the language code and a copy of
words, initializes an internal change channel, and sets a no-op logger.
lang := obj.CurrentLanguage() // returns the current ISO code
m := obj.Words() // returns a COPY of the dictionary
v := obj.Get(id, def) // returns def if id is empty or missingCurrentLanguageandWordstake read locks;Wordsreturns a defensive copy so external modifications cannot mutate internal state.Getlogs misses in the form"undef: <id>"viaobj.logand returns the provided default.
err := obj.SetLanguage(isoLanguage string, words map[string]string)- Validates input as in
New; returnsErrNilIsoLang/ErrNilWordson invalid values. - Returns
ErrClosedif the object was closed. - Returns
ErrLangAlreadySetifisoLanguageequals the current one. - On success, atomically swaps the language and a copy of the provided map, closes the internal change channel to notify waiters, then creates a fresh channel for future waits.
type EventType byte
const (
EventClose EventType = 0
EventLanguageChanged EventType = 4
)
ev := obj.Wait() // blocks until language changes or object is closed
ok := obj.WaitUntilClosed() // true if it was closed, false otherwiseWaitblocks on the internal channel. When it unblocks, it inspects theclosedflag:EventCloseif closed, otherwiseEventLanguageChanged.WaitUntilClosedis a convenience that returnstrueiff the closure event was received.
Typical loop:
go func () {
for {
switch obj.Wait() {
case language_wizard.EventLanguageChanged:
// Rebuild caches / refresh UI here.
case language_wizard.EventClose:
// Cleanup and exit.
return
}
}
}()Context loop:
go func () {
select {
case <-ctx.Done():
return
case <-obj.WaitChan():
if obj.IsClosed(){
// Cleanup and exit.
return
}
// Rebuild caches / refresh UI here.
}
}
}()obj.SetLog(func (msg string) { /* ... */ })- Sets a custom logger for undefined key lookups;
nilis ignored. The logger is stored under a write lock. - Only
Getcalls the logger (for misses).
obj.Close()- Idempotent. Sets
closed, closes the change channel (unblockingWait), and clears the words map to an empty one. FurtherSetLanguagecalls will fail withErrClosed.
Exported errors:
ErrNilIsoLang— ISO language is required byNew/SetLanguage.ErrNilWords—wordsmust be non-nil and non-empty inNew/SetLanguage.ErrLangAlreadySet— attempted to set the same language as current.ErrClosed— the object has been closed; updates are not allowed.
- The struct holds a
sync.RWMutex; readers (CurrentLanguage,Words,Get) take an RLock; writers (SetLanguage,SetLog,Close) take a Lock. SetLanguagecloses the current change channel to notify all waiters, then immediately replaces it with a new channel so subsequentWaitcalls will block until the next event.Waitreads a snapshot of the channel under a short lock, waits on it, then distinguishes “close” vs “language-changed” by checking theclosedflag under an RLock.
func greet(obj *language_wizard.LanguageWizardObj) string {
return obj.Get("hi", "Hello")
}This shields you from missing keys while still surfacing them via the logger.
func watch(obj *language_wizard.LanguageWizardObj) {
for {
switch obj.Wait() {
case language_wizard.EventLanguageChanged:
// e.g., warm up templates or invalidate caches
case language_wizard.EventClose:
return
}
}
}Use this from a goroutine to keep ancillary state in sync with the active language.
_ = obj.SetLanguage("fr", map[string]string{"hi": "Bonjour"})All current waiters are notified; subsequent waits latch onto the fresh channel.
obj.SetLog(func (s string) {
// s looks like: "undef: some.missing.key"
})Great for collecting telemetry on missing translations.
Run the test suite:
go test ./...What’s covered:
- Successful construction and basic lookups.
- Defensive copy semantics for
Words(). Getdefaulting and miss logging.- Validation and error cases in
New/SetLanguage. - Language switching and current language updates.
- Event handling:
Wait,WaitUntilClosed, and close behavior. Closeclears words and blocks further updates.
Q: Why does Wait sometimes return immediately after I call it twice?
Because SetLanguage and Close close the current event channel; if you call Wait again without a
subsequent SetLanguage, you may still be observing the already-closed channel. The implementation replaces the
channel after closing it; call Wait in a loop and treat each return as a single event.
Q: Can I mutate the map returned by Words()?
Yes, it’s a copy. Mutating it won’t affect the internal state. Use SetLanguage to replace the internal map.
Q: What happens after Close()?
Wait unblocks with EventClose, the dictionary is cleared, and SetLanguage returns ErrClosed. Reads still work
but the dictionary is empty unless you held an external copy.
After Close() is called, read methods (Get, CurrentLanguage, Words) remain fully functional and do not
return errors or panics. However, Close() clears the internal dictionary to an empty map, so:
Get(id, def)will always returndef(the default) for every key and will log"undef: <id>"for each call.CurrentLanguage()will still return the last language code that was set before closing, even though the object is no longer usable for updates.Words()will return an empty map.
This means there is no way to distinguish "the key is genuinely missing from the current translation" from "the object
has been closed" by looking at Get return values alone. If your code needs to detect closure, check IsClosed()
explicitly before or after calling Get.
if obj.IsClosed() {
// handle closed state
return
}
val := obj.Get("greeting", "Hello")Once Close() is called, the internal change channel is closed permanently and is never replaced. This has the
following consequences:
- The first
Wait()call that is blocked at the time ofClose()will correctly unblock and returnEventClose. - Any subsequent
Wait()calls afterClose()will also returnEventCloseimmediately (reading from a closed channel in Go returns the zero value without blocking). - If your code calls
Wait()in a loop, it will spin indefinitely after closure unless you explicitly check forEventCloseand break out:
for {
switch obj.Wait() {
case language_wizard.EventLanguageChanged:
// handle language change
case language_wizard.EventClose:
return // IMPORTANT: you must exit the loop here
}
}Without the return (or break) on EventClose, the loop becomes a busy spin that consumes 100% of a CPU core,
because Wait() never blocks again after the object is closed.
- Dictionary-only i18n: no ICU/plural rules, interpolation, or fallback chains—intentionally minimal.
- Blocking waits have no timeout or context cancellation; implement your own goroutine cancellation if needed.
- Language identity equality is string-based;
SetLanguage("en", …)to"en"returnsErrLangAlreadySet.