Flexible JSON unmarshalling for Go, forgiving of dirty real-world data.
d3rty/json parses JSON from sources you can't fully control or trust: third-party APIs with schema flakiness, legacy feeds, AI-generated output. Stringified numbers, "on"/"off" booleans, drifting key casing - instead of failing on the first mismatch, it decodes strictly first and falls back to a forgiving dirty schema you define per model.
Note
Work in progress: the API may still change before v1.
data := []byte(`{"id": "123"}`) var plain struct{ ID int `json:"id"` } json.Unmarshal(data, &plain) // error: cannot unmarshal string into Go struct field .id of type int var event Event // same shape, dirty-enabled (see Quick Start) dirty.Unmarshal(data, &event) // nil; event.ID == 123
go get github.com/d3rty/json
import dirty "github.com/d3rty/json"
Embed dirty.Enabled into your clean model and describe what may be dirty in a Dirty() counterpart. Everything else stays strict.
package main import ( "fmt" "log" dirty "github.com/d3rty/json" ) // Event is a regular "clean" model with strict types. type Event struct { dirty.Enabled // opts this model into dirty unmarshalling Name string `json:"name"` // Covered by the dirty schema below: stringified numbers, // sloppy booleans, etc. are forgiven. ID int `json:"id"` IsActive bool `json:"is_active"` // Not covered by the dirty schema: must be a real bool or it's lost. MustBool bool `json:"must_bool"` } // Dirty links the clean model to its dirty counterpart. func (e *Event) Dirty() any { return &struct { ID dirty.Number `json:"id"` IsActive dirty.Bool `json:"is_active"` }{} } func main() { data := []byte(`{"id": "123", "name": "Sample Event", "is_active": "on", "must_bool": true}`) var event Event if err := dirty.Unmarshal(data, &event); err != nil { log.Fatal(err) } fmt.Println(event.ID) // 123 fmt.Println(event.IsActive) // true fmt.Println(event.MustBool) // true }
The dirty types are dirty.Number, dirty.String, dirty.Bool, dirty.Array and dirty.Object. Swap dirty.Enabled for dirty.Disabled to switch a model back to strict stdlib behavior without touching anything else.
Key naming drifts too: isActive today, IsActive tomorrow, is-active from the mobile team. The FlexKeys option (off by default) matches keys case-insensitively and across camelCase/snake_case/kebab-case/PascalCase:
dirty.ConfigSetGlobal(func(cfg *dirty.Config) { cfg.FlexKeys.Disabled = false cfg.FlexKeys.CaseInsensitive = true cfg.FlexKeys.ChameleonCase = true }) // key arrives as PascalCase, value as a string - both forgiven data := []byte(`{"IsActive": "on"}`)
The case-detection machinery is exposed as its own package, cases.
Every forgiveness rule is a switch in a TOML config: which strings count as true, whether "1 000 000" or "1e6" are numbers, which date layouts to try, and so on. The full annotated list of defaults lives in default.toml.
// tweak the global config in place dirty.ConfigSetGlobal(func(cfg *dirty.Config) { cfg.Bool.FromStrings.CustomListForTrue = []string{"true", "yes", "on", "da"} }) // or load a full config from TOML bytes cfg := dirty.ConfigFromBytes(tomlBytes)
Unmarshalling results are classified by color: 🟢 green (decoded cleanly), 🟡 yellow (decoded via forgiving conversions), 🔴 red (data was lost). Reporting this back to the caller is not implemented yet.
The WASM demo runs the real decoder in your browser: paste dirty JSON, toggle config switches, see what gets forgiven.
d3rty/json is a solo, opinionated project - but if you stumbled upon it and have ideas, questions, or bug reports, an issue is always welcome :)