all repos

json2go @ af830300d091d01c29c65a9cd666325590dc1eae

convert json to go type annotations
6 files changed, 355 insertions(+), 0 deletions(-)
init
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2025-11-26 19:49:04 +0200
Change ID: qmytrmvzkqwpnurmwxxmtpuyqqxqqpur
A .github/workflows/go.yml

@@ -0,0 +1,26 @@

+name: go +on: + workflow_dispatch: + push: + branches: [main] + paths: ["**.go", "go.mod", "go.sum"] + pull_request: + paths: ["**.go", "go.mod", "go.sum"] + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: setup go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache-dependency-path: go.mod + + - name: build + run: go build ./cmd/json2go + + - name: test + run: go test -v ./...
A cmd/json2go/main.go

@@ -0,0 +1,9 @@

+package main + +// todo: piped / sedin +// todo: passed as argument +// todo: read from file +// todo: set name of the type + +func main() { +}
A go.mod

@@ -0,0 +1,3 @@

+module olexsmir.xyz/json2go + +go 1.25.3
A json2go.go

@@ -0,0 +1,187 @@

+package json2go + +import ( + "encoding/json" + "errors" + "fmt" + "strings" +) + +var ErrInvalidJSON = errors.New("invalid json") + +type ( + types struct{ name, def string } + Transformer struct { + structName string + types []types + } +) + +func NewTransformer() *Transformer { + return &Transformer{} +} + +// Transform ... +// todo: take io.Reader as input? +// todo: output as io.Writer? +// todo: validate provided structName +func (t *Transformer) Transform(structName, jsonStr string) (string, error) { + t.structName = structName + t.types = make([]types, 1) + + var input any + if err := json.Unmarshal([]byte(jsonStr), &input); err != nil { + return "", errors.Join(ErrInvalidJSON, err) + } + + var result strings.Builder + + // the "parent" type + type_ := t.generateTypeAnnotation(structName, input) + result.WriteString(type_) + + // nested types + for _, t := range t.types { + if t.name != structName { + result.WriteString(t.def) + result.WriteString("\n") + } + } + + return result.String(), nil +} + +func (t *Transformer) generateTypeAnnotation(typeName string, input any) string { + switch v := input.(type) { + case map[string]any: + return t.buildStruct(typeName, v) + + case []any: + if len(v) == 0 { + return fmt.Sprintf("type %s []any", t.structName) + } + + type_ := t.getGoType(typeName+"Item", v[0]) + return fmt.Sprintf("type %s []%s", typeName, type_) + + case string: + return fmt.Sprintf("type %s string", typeName) + + case float64: + if float64(int(v)) == v { + return fmt.Sprintf("type %s int", typeName) + } + return fmt.Sprintf("type %s float64", typeName) + + case bool: + return fmt.Sprintf("type %s bool", typeName) + + case nil: + return fmt.Sprintf("type %s any", typeName) + + default: + return fmt.Sprintf("type %s any", typeName) + + } +} + +// todo: input shouldn't be map, to preserve it's order +func (t *Transformer) buildStruct(typeName string, input map[string]any) string { + var fields strings.Builder + for key, value := range input { + fieldName := t.toGoFieldName(key) + if fieldName == "" { + fieldName = "Field" + } + + fieldType := t.getGoType(fieldName, value) + + // todo: toggle json tags generation + jsonTag := fmt.Sprintf("`json:\"%s\"`", key) + + // todo: figure out the indentation, since it might have nested struct + fields.WriteString(fmt.Sprintf( + "%s %s %s\n", + fieldName, + fieldType, + jsonTag, + )) + } + + structDef := fmt.Sprintf("type %s struct {\n%s}", typeName, fields.String()) + t.types = append(t.types, types{ + name: typeName, + def: structDef, + }) + + return structDef +} + +func (t *Transformer) getGoType(fieldName string, value any) string { + switch v := value.(type) { + case map[string]any: + typeName := t.toGoTypeName(fieldName) + if !t.isTypeRecorded(typeName) { + t.generateTypeAnnotation(typeName, v) + } + return typeName + + case []any: + if len(v) == 0 { + return "[]any" + } + + type_ := t.getGoType(fieldName+"Item", v[0]) // TODO + return "[]" + type_ + + case float64: + if float64(int(v)) == v { + return "int" + } + return "float64" + + case string: + return "string" + + case bool: + return "bool" + + case nil: + return "any" + + default: + return "any" + } +} + +func (t *Transformer) toGoTypeName(fieldName string) string { + goName := t.toGoFieldName(fieldName) + if len(goName) > 0 { + return strings.ToUpper(goName[:1]) + goName[1:] + } + return "Type" +} + +func (t *Transformer) toGoFieldName(jsonField string) string { + parts := strings.Split(jsonField, "_") + + var result strings.Builder + for _, part := range parts { + if part != "" { + if len(part) > 0 { + result.WriteString(strings.ToUpper(part[:1]) + part[1:]) + } + } + } + + return result.String() +} + +func (t *Transformer) isTypeRecorded(name string) bool { + for _, t := range t.types { + if t.name == name { + return true + } + } + return false +}
A json2go_test.go

@@ -0,0 +1,114 @@

+package json2go + +import ( + "errors" + "fmt" + "strings" + "testing" +) + +func field(name, type_ string, json_ ...string) string { + tag := strings.ToLower(name) + if len(json_) == 1 { + tag = json_[0] + } + return fmt.Sprintf("\n%s %s `json:\"%s\"`", name, type_, tag) +} + +func TestTransformer_Transform(t *testing.T) { + tests := map[string]struct { + input string + output string + err error + }{ + "simple object": { + input: `{"name": "Olex", "active": true, "age": 420}`, + output: "type Out struct {" + + field("Name", "string") + + field("Active", "bool") + + field("Age", "int") + + "\n}\n", + }, + "invalid json": { + err: ErrInvalidJSON, + input: `{"invalid":json}`, + }, + "snake_case to CamelCase": { + input: `{"first_name": "Bob", "last_name": "Bobberson"}`, + output: "type Out struct {" + + field("FirstName", "string", "first_name") + + field("LastName", "string", "last_name") + + "\n}\n", + }, + "nested object and array": { + input: `{"user": {"name": "Alice", "score": 95.5}, "tags": ["go", "json"]}`, + output: "type Out struct {" + + field("User", "User") + + field("Tags", "[]string") + + "\n}\ntype User struct {" + + field("Name", "string") + + field("Score", "float64") + + "\n}\n", + }, + "empty nested object": { + input: `{"user": {}}`, + output: "type Out struct {" + + field("User", "User") + + "\n}\ntype User struct {\n}\n", + }, + "array of object": { + input: `[{"name": "John"}, {"name": "Jane"}]`, + output: "type Out []OutItem" + + "\ntype OutItem struct {" + + field("Name", "string") + + "\n}\n", + }, + "empty array": { + input: `{"items": []}`, + output: `type Out struct {` + + field("Items", "[]any") + "\n}\n", + }, + "null": { + input: `{"item": null}`, + output: `type Out struct {` + + field("Item", "any") + "\n}\n", + }, + "numbers": { + input: `{"pos": 123, "neg": -321, "float": 420.69}`, + output: "type Out struct {" + + field("Pos", "int") + + field("Neg", "int") + + field("Float", "float64") + + "\n}\n", + }, + } + + trans := NewTransformer() + for tname, tt := range tests { + t.Run(tname, func(t *testing.T) { + result, err := trans.Transform("Out", tt.input) + assertEqualErr(t, tt.err, err) + + lines := strings.Split(result, "\n") + counts := make(map[string]int) + for _, line := range lines { + if !strings.Contains(line, "}") { + counts[line]++ + } + } + + for _, line := range lines { + if counts[line] > 1 { + t.Fatalf("found duplicate line: %s", line) + } + } + }) + } +} + +func assertEqualErr(t *testing.T, expected, actual error) { + t.Helper() + if (expected != nil || actual != nil) && errors.Is(expected, actual) { + t.Errorf("expected: %v, got: %v\n", expected, actual) + } +}
A readme.txt

@@ -0,0 +1,16 @@

+json2go +------- + +json2go to go provides a library and cli tool for +convening json strings to go struct definitions + + t := json2go. NewTransformer() + typedef, _ := t.Transform(jsonStr, "TypeName") + + +cli interface: + + go install olexsmir.xyz/json2go/cmd/json2go@latest + + echo "{...}" | json2go + json2go "{...}"