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.WaitAndClose() // true if it was closed, false otherwiseWaitblocks on the internal channel. When it unblocks, it inspects theclosedflag:EventCloseif closed, otherwiseEventLanguageChanged.WaitAndCloseis 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
}
}
}()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,WaitAndClose, 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. 
- 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.