Skip to content

JSON to Zod Schema — Go source

Generate Zod validation schemas from JSON. Infers z.string, z.number, z.boolean, z.object, z.array, z.null, and z.union for mixed arrays.

This is the Go implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Package jsontozod is the Go twin of CosmoDev's src/lib/jsonToZod.ts (dual
// source: the web lib is TypeScript, the CLI lib is Go — kept in lock-step).
// Pure + deterministic, never panics. The table-driven tests in
// json-to-zod_test.go share vectors with src/lib/jsonToZod.test.ts so the two
// implementations are held to the same contract.
//
// The converter recursively infers z.* validators from a JSON string; mixed
// arrays become z.union. It mirrors the TS lib exactly, including object key
// order: the TS lib iterates Object.entries (insertion = textual order), so the
// Go twin parses with a token-based json.Decoder that preserves key order — the
// standard map[string]any randomizes keys and would break parity.
package jsontozod

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"strings"
)

// Options configures JSONToZod. The zero value (Options{}) matches the TS
// default (jsonToZod(jsonString) with no options): root name "Root".
type Options struct {
	RootName string // "" → "Root" (default), matching opts.rootName || 'Root'
}

// Result is the outcome of a conversion, mirroring the TS Result interface.
// Error is "" (zero value) when there was no error, standing in for the TS
// `error: string | null` null case.
type Result struct {
	Ok    bool
	Code  string
	Error string
}

// maxDepth bounds recursion for both parsing and inference. Go goroutine stacks
// grow dynamically (unlike the fixed JS call stack), so without a cap a patho-
// logically deep input would never overflow the way it does in TS. The cap
// mirrors Go encoding/json's own nesting limit and guarantees the TS
// "overflows the recursion stack" vector (50000-deep) fails gracefully instead
// of panicking.
const maxDepth = 10000

var errDepthExceeded = errors.New("input nested too deeply (recursion limit exceeded)")

// orderedObject is a JSON object whose key order is preserved (textual order),
// mirroring the TS lib's Object.entries iteration. Duplicate keys keep their
// first-occurrence position and last-assigned value, exactly like JSON.parse.
type orderedObject struct {
	keys []string
	vals map[string]any
}

// JSONToZod converts a JSON string into a Zod schema string. It is the Go twin
// of jsonToZod() in src/lib/jsonToZod.ts and must agree with it on every shared
// vector. Invalid JSON and over-deep input return Ok=false with a non-empty
// Error, never a panic.
func JSONToZod(jsonString string, opts Options) Result {
	value, err := parseJSON(jsonString)
	if err != nil {
		return Result{Ok: false, Code: "", Error: err.Error()}
	}
	rootName := opts.RootName
	if rootName == "" {
		rootName = "Root"
	}
	rootName = sanitizeVarName(rootName)
	code, err := infer(value, 2, 0)
	if err != nil {
		return Result{Ok: false, Code: "", Error: err.Error()}
	}
	return Result{Ok: true, Code: "const " + rootName + " = " + code + ";", Error: ""}
}

// InferZod infers a Zod schema string for a parsed value. It is the Go twin of
// inferZod() in src/lib/jsonToZod.ts. indent is the current indentation depth
// (pass 2 for a top-level value, matching the TS default). Objects produced by
// JSONToZod preserve key order; values supplied directly as map[string]any
// do not (Go maps are unordered) — prefer JSONToZod for ordered object output.
func InferZod(value any, indent int) (string, error) {
	if indent < 0 {
		indent = 0
	}
	return infer(value, indent, 0)
}

// infer is the recursive core shared by JSONToZod and InferZod. depth tracks
// recursion depth so pathologically deep input errors instead of overflowing.
func infer(value any, indent, depth int) (string, error) {
	if depth > maxDepth {
		return "", errDepthExceeded
	}
	if value == nil {
		return "z.null()", nil
	}
	switch x := value.(type) {
	case string:
		return "z.string()", nil
	case float64:
		return "z.number()", nil
	case bool:
		return "z.boolean()", nil
	case []any:
		return inferArray(x, indent, depth)
	case *orderedObject:
		return inferObject(x, indent, depth)
	default:
		// Anything that is not null/string/number/boolean/array/object maps to
		// z.unknown(), mirroring the TS fallthrough (e.g. undefined).
		return "z.unknown()", nil
	}
}

// inferArray mirrors the Array branch of inferZod. An empty array is
// z.array(z.unknown()); a homogeneous array collapses to a single inner type;
// a mixed array becomes z.union. Note the union lists EVERY element's type
// (with duplicates), not just the distinct ones — exactly like the TS lib,
// which builds the union from `types` and uses `distinct` only to choose
// single-vs-union.
func inferArray(arr []any, indent, depth int) (string, error) {
	if len(arr) == 0 {
		return "z.array(z.unknown())", nil
	}
	types := make([]string, len(arr))
	for i, e := range arr {
		s, err := infer(e, indent+2, depth+1)
		if err != nil {
			return "", err
		}
		types[i] = s
	}
	seen := make(map[string]bool, len(types))
	var distinct []string
	for _, t := range types {
		if !seen[t] {
			seen[t] = true
			distinct = append(distinct, t)
		}
	}
	var inner string
	if len(distinct) == 1 {
		inner = distinct[0]
	} else {
		joined := strings.Join(types, ",\n")
		inner = fmt.Sprintf("z.union([\n%s\n%s])", pad(joined, indent+2), pad("", indent))
	}
	return "z.array(" + inner + ")", nil
}

// inferObject mirrors the plain-object branch of inferZod: one indented field
// per entry, in key order, each trailing with a comma.
func inferObject(obj *orderedObject, indent, depth int) (string, error) {
	pad0 := strings.Repeat(" ", indent)
	pad1 := strings.Repeat(" ", indent+2)
	if len(obj.keys) == 0 {
		return "z.object({})", nil
	}
	fields := make([]string, len(obj.keys))
	for i, k := range obj.keys {
		s, err := infer(obj.vals[k], indent+2, depth+1)
		if err != nil {
			return "", err
		}
		fields[i] = pad1 + k + ": " + s + ","
	}
	return "z.object({\n" + strings.Join(fields, "\n") + "\n" + pad0 + "})", nil
}

// pad prepends `depth` spaces to every non-empty line of s, leaving blank lines
// untouched. It mirrors the pad() helper in the TS lib.
func pad(s string, depth int) string {
	p := strings.Repeat(" ", depth)
	lines := strings.Split(s, "\n")
	for i, l := range lines {
		if len(l) > 0 {
			lines[i] = p + l
		}
	}
	return strings.Join(lines, "\n")
}

// sanitizeVarName turns an arbitrary root name into a valid JS identifier,
// mirroring sanitizeVarName() in the TS lib: strip every char outside
// [A-Za-z0-9_$], replace a leading run of digits with that many underscores,
// and fall back to "schema" when nothing remains.
func sanitizeVarName(name string) string {
	var b strings.Builder
	for _, r := range name {
		if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') ||
			(r >= '0' && r <= '9') || r == '_' || r == '$' {
			b.WriteRune(r)
		}
	}
	cleaned := b.String()
	runes := []rune(cleaned)
	n := 0
	for n < len(runes) && runes[n] >= '0' && runes[n] <= '9' {
		n++
	}
	if n > 0 {
		cleaned = strings.Repeat("_", n) + string(runes[n:])
	}
	if cleaned == "" {
		return "schema"
	}
	return cleaned
}

// parseJSON parses s into an order-preserving value tree (orderedObject for
// objects, []any for arrays, and standard scalar types otherwise). It
// errors on invalid JSON and on trailing non-whitespace content, matching
// JSON.parse.
func parseJSON(s string) (any, error) {
	dec := json.NewDecoder(strings.NewReader(s))
	value, err := parseValue(dec, 0)
	if err != nil {
		return nil, err
	}
	// JSON.parse rejects trailing non-whitespace content; surface the same error.
	if _, err := dec.Token(); err != io.EOF {
		if err == nil {
			return nil, errors.New("unexpected trailing content after JSON value")
		}
		return nil, err
	}
	return value, nil
}

// parseValue reads one JSON value from dec. depth bounds nesting.
func parseValue(dec *json.Decoder, depth int) (any, error) {
	if depth > maxDepth {
		return nil, errDepthExceeded
	}
	tok, err := dec.Token()
	if err != nil {
		return nil, err
	}
	return decodeToken(dec, tok, depth)
}

// decodeToken interprets a token already consumed from dec, recursing into
// containers via decodeObject/decodeArray.
func decodeToken(dec *json.Decoder, tok json.Token, depth int) (any, error) {
	if delim, ok := tok.(json.Delim); ok {
		switch delim {
		case '{':
			return decodeObject(dec, depth)
		case '[':
			return decodeArray(dec, depth)
		}
		return nil, fmt.Errorf("unexpected delimiter %q", string(delim))
	}
	switch v := tok.(type) {
	case bool:
		return v, nil
	case string:
		return v, nil
	case float64:
		return v, nil
	case json.Number:
		// The magnitude is irrelevant to Zod type inference (any number →
		// z.number()), so avoid a Float64() overflow error and just mark the
		// value as a number.
		return float64(0), nil
	case nil:
		return nil, nil
	default:
		return nil, fmt.Errorf("unexpected token %T", tok)
	}
}

// decodeObject reads a JSON object whose opening '{' has been consumed,
// preserving key order.
func decodeObject(dec *json.Decoder, depth int) (any, error) {
	if depth > maxDepth {
		return nil, errDepthExceeded
	}
	obj := &orderedObject{vals: map[string]any{}}
	for {
		tok, err := dec.Token()
		if err != nil {
			return nil, err
		}
		if delim, ok := tok.(json.Delim); ok && delim == '}' {
			return obj, nil
		}
		key, ok := tok.(string)
		if !ok {
			return nil, fmt.Errorf("expected object key string, got %T", tok)
		}
		val, err := parseValue(dec, depth+1)
		if err != nil {
			return nil, err
		}
		if _, exists := obj.vals[key]; !exists {
			obj.keys = append(obj.keys, key)
		}
		obj.vals[key] = val
	}
}

// decodeArray reads a JSON array whose opening '[' has been consumed. An empty
// array yields a non-nil zero-length slice so len()==0 detects it.
func decodeArray(dec *json.Decoder, depth int) (any, error) {
	if depth > maxDepth {
		return nil, errDepthExceeded
	}
	arr := []any{}
	for {
		tok, err := dec.Token()
		if err != nil {
			return nil, err
		}
		if delim, ok := tok.(json.Delim); ok && delim == ']' {
			return arr, nil
		}
		val, err := decodeToken(dec, tok, depth+1)
		if err != nil {
			return nil, err
		}
		arr = append(arr, val)
	}
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →