Skip to content

JSON → TypeScript — Go source

Paste any JSON and instantly get clean, typed TypeScript interfaces - primitives, nested objects, arrays and unions, all inferred. Optional keys, reserved-word quoting, and shape dedup are handled for you. Runs 100% in your browser.

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

// Package jsontotypescript is the Go twin of CosmoDev's
// src/lib/json-to-typescript.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-typescript_test.go share vectors with
// src/lib/json-to-typescript.test.ts so the two implementations are held to the
// same contract.
//
// Algorithm mirrors the TS lib exactly: walk a JSON value inferring a tree of
// type nodes (object / union / array / primitive / unknown), deduplicate object
// shapes by structural signature, assign unique PascalCase interface names, and
// render TypeScript interfaces (for object roots) or type aliases (for
// primitive / unknown / array roots).
//
// Object key order is preserved (first-seen order drives interface property
// order), mirroring JS Object.keys semantics. encoding/json alone
// (map[string]any) randomizes key order, which would scramble property order and
// break parity with the TS lib — so, like cli/json-csv, we parse tokens
// ourselves into an ordered value tree.
package jsontotypescript

import (
	"encoding/json"
	"errors"
	"fmt"
	"reflect"
	"regexp"
	"sort"
	"strings"
)

// Options configures JsonToTs. The zero value (Options{}) matches the TS default
// (jsonToTs(value) with no options): root name "Root", heterogeneous arrays
// merged, nullable properties kept required.
type Options struct {
	// RootName is the name of the root interface/type. "" → "Root" (default).
	RootName string
	// UnionArrays is false (default) to merge heterogeneous array element types
	// (objects union their keys — missing keys become optional; distinct
	// primitives form a union); true emits a true union of distinct elements.
	UnionArrays bool
	// OptionalNullable, when true, marks object properties whose type includes
	// null as optional (e.g. { "a": null } → a?: null instead of a: null).
	OptionalNullable bool
}

type resolvedOpts struct {
	rootName         string
	unionArrays      bool
	optionalNullable bool
}

func resolveOpts(o Options) resolvedOpts {
	r := resolvedOpts{unionArrays: o.UnionArrays, optionalNullable: o.OptionalNullable}
	rn := o.RootName
	if rn == "" {
		rn = "Root" // mirror TS: opts.rootName ?? 'Root'
	}
	r.rootName = sanitizeRoot(rn)
	return r
}

// ---- ordered JSON value tree ------------------------------------------------

// jval is a JSON value that preserves object member insertion order.
// encoding/json (map[string]any) randomizes key order, which would break parity
// with the TS lib's Object.keys order, so we parse tokens ourselves. Mirrors the
// jval tree in cli/json-csv.
type jval struct {
	kind    byte             // 'o' object, 'a' array, 's' string, 'n' number, 'b' bool, 'z' null
	str     string           // kind 's' literal; kind 'n' number text
	boolean bool             // kind 'b'
	keys    []string         // kind 'o': member keys in first-seen order
	members map[string]*jval // kind 'o'
	items   []*jval          // kind 'a'
}

// DecodeJSON parses one JSON value into an ordered tree. It is the parsing
// helper a caller pairs with JsonToTs (which mirrors the TS jsonToTs(value)
// surface). Object keys keep their first-seen order; a duplicate key keeps its
// first position but lets the last value win (like JSON.parse + Object.keys).
func DecodeJSON(s string) (any, error) {
	return parseJSON(s)
}

// parseJSON parses one JSON value (strict: no trailing data, else error).
func parseJSON(s string) (*jval, error) {
	dec := json.NewDecoder(strings.NewReader(s))
	dec.UseNumber() // keep number literal text (consistent with cli/json-csv)
	v, err := parseValue(dec)
	if err != nil {
		return nil, err
	}
	if dec.More() {
		return nil, errors.New("json: unexpected trailing data")
	}
	return v, nil
}

func parseValue(dec *json.Decoder) (*jval, error) {
	tok, err := dec.Token()
	if err != nil {
		return nil, err
	}
	return tokenToValue(tok, dec)
}

func tokenToValue(tok json.Token, dec *json.Decoder) (*jval, error) {
	switch t := tok.(type) {
	case json.Delim:
		switch t {
		case '{':
			return parseObject(dec)
		case '[':
			return parseArray(dec)
		default:
			return nil, fmt.Errorf("json: unexpected delim %q", string(rune(t)))
		}
	case bool:
		return &jval{kind: 'b', boolean: t}, nil
	case string:
		return &jval{kind: 's', str: t}, nil
	case json.Number:
		return &jval{kind: 'n', str: string(t)}, nil
	case nil:
		return &jval{kind: 'z'}, nil
	default:
		return nil, fmt.Errorf("json: unexpected token %T", t)
	}
}

func parseObject(dec *json.Decoder) (*jval, error) {
	o := &jval{kind: 'o', members: map[string]*jval{}}
	for dec.More() {
		keyTok, err := dec.Token()
		if err != nil {
			return nil, err
		}
		key, ok := keyTok.(string)
		if !ok {
			return nil, errors.New("json: object key is not a string")
		}
		val, err := parseValue(dec)
		if err != nil {
			return nil, err
		}
		// Duplicate key: keep first-seen position, let last value win
		// (JSON.parse + Object.keys semantics).
		if _, exists := o.members[key]; !exists {
			o.keys = append(o.keys, key)
		}
		o.members[key] = val
	}
	if _, err := dec.Token(); err != nil { // consume '}'
		return nil, err
	}
	return o, nil
}

func parseArray(dec *json.Decoder) (*jval, error) {
	a := &jval{kind: 'a'}
	for dec.More() {
		val, err := parseValue(dec)
		if err != nil {
			return nil, err
		}
		a.items = append(a.items, val)
	}
	if _, err := dec.Token(); err != nil { // consume ']'
		return nil, err
	}
	return a, nil
}

// toJval normalizes an input value into the typed jval tree, rejecting anything
// that is not JSON-serializable — the Go analog of the TS lib's
// assertJsonSerializable guard. A *jval (e.g. from DecodeJSON) passes through.
// Raw Go JSON values (bool/number/string/nil/[]any/map[string]any) are wrapped;
// map[string]any has no insertion order, so its keys are sorted for determinism
// (callers that need TS-exact key order should use DecodeJSON).
func toJval(value any, path string) (*jval, error) {
	loc := path
	if loc == "" {
		loc = "root"
	}
	switch v := value.(type) {
	case nil:
		return &jval{kind: 'z'}, nil
	case bool:
		return &jval{kind: 'b', boolean: v}, nil
	case string:
		return &jval{kind: 's', str: v}, nil
	case float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
		return &jval{kind: 'n'}, nil
	case []any:
		a := &jval{kind: 'a'}
		for i, e := range v {
			ev, err := toJval(e, fmt.Sprintf("%s[%d]", path, i))
			if err != nil {
				return nil, err
			}
			a.items = append(a.items, ev)
		}
		return a, nil
	case map[string]any:
		o := &jval{kind: 'o', members: map[string]*jval{}}
		keys := make([]string, 0, len(v))
		for k := range v {
			keys = append(keys, k)
		}
		sort.Strings(keys)
		for _, k := range keys {
			ev, err := toJval(v[k], path+"."+k)
			if err != nil {
				return nil, err
			}
			o.keys = append(o.keys, k)
			o.members[k] = ev
		}
		return o, nil
	case *jval:
		return v, nil
	default:
		rk := reflect.ValueOf(value).Kind()
		switch rk {
		case reflect.Struct, reflect.Map:
			return nil, fmt.Errorf("Value at %s is not a plain JSON object", loc)
		default:
			return nil, fmt.Errorf("Value at %s is not JSON-serializable (%s)", loc, rk)
		}
	}
}

// ---- type nodes -------------------------------------------------------------

type kind int

const (
	kindPrimitive kind = iota
	kindObject
	kindArray
	kindUnion
	kindUnknown
)

// typeNode mirrors the TS TypeNode discriminated union.
type typeNode struct {
	kind     kind
	ts       string     // kindPrimitive
	props    []propNode // kindObject
	sig      string     // kindObject / kindUnion: cached structural signature
	nameHint string     // kindObject: path-derived interface name hint
	of       *typeNode  // kindArray: element type (nil → unknown → unknown[])
	members  []typeNode // kindUnion
}

type propNode struct {
	key      string
	typ      typeNode
	optional bool
}

var unknownNode = typeNode{kind: kindUnknown}
var nullNode = typeNode{kind: kindPrimitive, ts: "null"}

// TypeScript reserved words + built-in type names — must be quoted as keys.
var reserved = map[string]bool{
	"break": true, "case": true, "catch": true, "class": true, "const": true,
	"continue": true, "debugger": true, "default": true, "delete": true, "do": true,
	"else": true, "enum": true, "export": true, "extends": true, "false": true,
	"finally": true, "for": true, "function": true, "if": true, "import": true,
	"in": true, "instanceof": true, "new": true, "null": true, "return": true,
	"super": true, "switch": true, "this": true, "throw": true, "true": true,
	"try": true, "typeof": true, "var": true, "void": true, "while": true,
	"with": true, "as": true, "async": true, "await": true, "yield": true,
	"let": true, "static": true, "implements": true, "interface": true,
	"package": true, "private": true, "protected": true, "public": true,
	"type": true, "readonly": true, "namespace": true, "abstract": true,
	"any": true, "boolean": true, "never": true, "number": true, "object": true,
	"string": true, "symbol": true, "undefined": true, "unknown": true,
	"keyof": true, "infer": true, "satisfies": true,
}

var (
	splitNonAlnum = regexp.MustCompile(`[^A-Za-z0-9]+`)
	identRe       = regexp.MustCompile(`^[A-Za-z_$][A-Za-z0-9_$]*$`)
)

// pascal PascalCases a key segment for use in interface names.
func pascal(key string) string {
	rawParts := splitNonAlnum.Split(key, -1)
	var parts []string
	for _, p := range rawParts {
		if p != "" {
			parts = append(parts, p)
		}
	}
	var head string
	if len(parts) == 0 {
		head = "Item"
	} else {
		var b strings.Builder
		for _, p := range parts {
			r := []rune(p)
			b.WriteString(strings.ToUpper(string(r[0])))
			b.WriteString(string(r[1:]))
		}
		head = b.String()
	}
	if head != "" && head[0] >= '0' && head[0] <= '9' {
		return "N" + head
	}
	return head
}

// singularize singularizes an interface name for array-element naming
// (e.g. "Items" → "Item"). Non-plural names get an "Item" suffix.
func singularize(name string) string {
	if len(name) > 1 && strings.HasSuffix(name, "s") && !strings.HasSuffix(name, "ss") {
		return name[:len(name)-1]
	}
	return name + "Item"
}

// sanitizeRoot sanitizes a user-provided root name into a valid PascalCase TS
// identifier.
func sanitizeRoot(name string) string {
	cleaned := pascal(name)
	if cleaned == "" {
		return "Root"
	}
	return cleaned
}

// signature is the structural signature of a node (independent of assigned
// names), used for shape dedup.
func signature(node typeNode) string {
	switch node.kind {
	case kindPrimitive:
		return node.ts
	case kindUnknown:
		return "?"
	case kindArray:
		if node.of != nil {
			return "[" + signature(*node.of) + "]"
		}
		return "[]"
	case kindUnion:
		parts := make([]string, len(node.members))
		for i, m := range node.members {
			parts[i] = signature(m)
		}
		return "(" + strings.Join(parts, "|") + ")"
	case kindObject:
		parts := make([]string, len(node.props))
		for i, p := range node.props {
			opt := ""
			if p.optional {
				opt = "?"
			}
			parts[i] = p.key + opt + ":" + signature(p.typ)
		}
		return "{" + strings.Join(parts, ";") + "}"
	}
	return ""
}

// containsNull reports whether a type contains a null member.
func containsNull(node typeNode) bool {
	switch node.kind {
	case kindPrimitive:
		return node.ts == "null"
	case kindUnion:
		for _, m := range node.members {
			if containsNull(m) {
				return true
			}
		}
	}
	return false
}

// dedupe deduplicates nodes by structural signature, preserving first-seen order.
func dedupe(nodes []typeNode) []typeNode {
	seen := make(map[string]bool, len(nodes))
	out := make([]typeNode, 0, len(nodes))
	for _, n := range nodes {
		s := signature(n)
		if !seen[s] {
			seen[s] = true
			out = append(out, n)
		}
	}
	return out
}

// makeUnion builds a union node from already-collected members.
func makeUnion(members []typeNode) typeNode {
	n := typeNode{kind: kindUnion, members: members}
	n.sig = signature(n)
	return n
}

// mergeObjects merges object nodes: union of keys (missing → optional), recursing
// per key. Mirrors mergeObjects() in the TS lib.
func mergeObjects(objs []typeNode, opts resolvedOpts) typeNode {
	if len(objs) == 0 {
		n := typeNode{kind: kindObject}
		n.sig = signature(n)
		return n
	}
	var keyOrder []string
	byKey := make(map[string][]typeNode)
	for _, o := range objs {
		for _, p := range o.props {
			if _, ok := byKey[p.key]; !ok {
				keyOrder = append(keyOrder, p.key)
				byKey[p.key] = nil
			}
			byKey[p.key] = append(byKey[p.key], p.typ)
		}
	}
	props := make([]propNode, 0, len(keyOrder))
	for _, key := range keyOrder {
		childTypes := byKey[key]
		typ := combine(childTypes, opts)
		optional := len(childTypes) < len(objs) // missing from some element
		if opts.optionalNullable && containsNull(typ) {
			optional = true
		}
		props = append(props, propNode{key: key, typ: typ, optional: optional})
	}
	n := typeNode{kind: kindObject, props: props, nameHint: objs[0].nameHint}
	n.sig = signature(n)
	return n
}

// combine combines a list of types into one. Mirrors combine() in the TS lib:
// empty → unknown; unionArrays → distinct union; else → merge (objects merge
// keys, distinct primitives union, heterogeneous → union).
func combine(nodes []typeNode, opts resolvedOpts) typeNode {
	if len(nodes) == 0 {
		return unknownNode
	}
	if opts.unionArrays {
		d := dedupe(nodes)
		if len(d) == 1 {
			return d[0]
		}
		return makeUnion(d)
	}

	var objs, arrs, prims []typeNode
	hasUnknown := false
	for _, n := range nodes {
		switch n.kind {
		case kindObject:
			objs = append(objs, n)
		case kindArray:
			arrs = append(arrs, n)
		case kindPrimitive:
			prims = append(prims, n)
		case kindUnknown:
			hasUnknown = true
			// A union node (or any other kind) is intentionally dropped here,
			// matching the TS lib's .filter(n => n.kind === ...) buckets.
		}
	}
	prims = dedupe(prims)

	// Pure primitive arrays collapse: identical → single, distinct → union.
	if len(objs) == 0 && len(arrs) == 0 {
		members := append([]typeNode{}, prims...)
		if hasUnknown {
			members = append(members, unknownNode)
		}
		d := dedupe(members)
		if len(d) == 1 {
			return d[0]
		}
		return makeUnion(d)
	}

	// Homogeneous object array → merge into one object.
	if len(objs) > 0 && len(arrs) == 0 && len(prims) == 0 && !hasUnknown {
		return mergeObjects(objs, opts)
	}

	// Otherwise build a union of the meaningful parts.
	members := make([]typeNode, 0, 2)
	if len(objs) > 0 {
		members = append(members, mergeObjects(objs, opts))
	}
	if len(arrs) > 0 {
		ofTypes := make([]typeNode, 0, len(arrs))
		for _, a := range arrs {
			if a.of != nil {
				ofTypes = append(ofTypes, *a.of)
			} else {
				ofTypes = append(ofTypes, unknownNode)
			}
		}
		var elem *typeNode
		if len(ofTypes) > 0 {
			c := combine(ofTypes, opts)
			elem = &c
		}
		members = append(members, typeNode{kind: kindArray, of: elem})
	}
	members = append(members, prims...)
	if hasUnknown {
		members = append(members, unknownNode)
	}
	d := dedupe(members)
	if len(d) == 1 {
		return d[0]
	}
	return makeUnion(d)
}

// infer recursively infers a typeNode from a JSON value. hint = interface name
// if the value is an object.
func infer(v *jval, hint string, opts resolvedOpts) typeNode {
	switch v.kind {
	case 'z':
		return nullNode
	case 'b':
		return typeNode{kind: kindPrimitive, ts: "boolean"}
	case 'n':
		return typeNode{kind: kindPrimitive, ts: "number"}
	case 's':
		return typeNode{kind: kindPrimitive, ts: "string"}
	case 'a':
		if len(v.items) == 0 {
			return typeNode{kind: kindArray} // of == nil → unknown[]
		}
		elemHint := singularize(hint)
		elements := make([]typeNode, len(v.items))
		for i, e := range v.items {
			elements[i] = infer(e, elemHint, opts)
		}
		c := combine(elements, opts)
		return typeNode{kind: kindArray, of: &c}
	case 'o':
		props := make([]propNode, 0, len(v.keys))
		for _, key := range v.keys {
			typ := infer(v.members[key], hint+pascal(key), opts)
			optional := false
			if opts.optionalNullable && containsNull(typ) {
				optional = true
			}
			props = append(props, propNode{key: key, typ: typ, optional: optional})
		}
		n := typeNode{kind: kindObject, props: props, nameHint: hint}
		n.sig = signature(n)
		return n
	}
	return unknownNode
}

// renderKey quotes a property key unless it is a valid unquoted TS identifier
// and not a reserved word.
func renderKey(key string) string {
	if identRe.MatchString(key) && !reserved[key] {
		return key
	}
	b, _ := json.Marshal(key) // JSON.stringify → quoted string
	return string(b)
}

// renderType renders a type node to its TS string form. Parens around union
// array elements are added by wrapArray at the array site, never here.
func renderType(node typeNode, names map[string]string) string {
	switch node.kind {
	case kindPrimitive:
		return node.ts
	case kindUnknown:
		return "unknown"
	case kindObject:
		if n, ok := names[node.sig]; ok {
			return n
		}
		return "unknown"
	case kindArray:
		if node.of != nil {
			return wrapArray(renderType(*node.of, names), *node.of) + "[]"
		}
		return "unknown[]"
	case kindUnion:
		d := dedupe(node.members)
		parts := make([]string, len(d))
		for i, m := range d {
			parts[i] = renderType(m, names)
		}
		return strings.Join(parts, " | ")
	}
	return ""
}

// wrapArray wraps an array element in parens if it would otherwise mis-parse
// (a union).
func wrapArray(rendered string, of typeNode) string {
	if of.kind == kindUnion {
		return "(" + rendered + ")"
	}
	return rendered
}

// collectObjects collects every object node (deduped by shape) in first-seen
// order, naming each. Names are unique: when two distinct shapes share a
// path-derived hint, later ones get a numeric suffix.
func collectObjects(root typeNode, rootName string) ([]typeNode, map[string]string) {
	names := make(map[string]string)
	usedNames := make(map[string]bool)
	var order []typeNode

	// Only the actual root node is named rootName; every other object takes its
	// path-derived hint (so an array-of-objects root names its element RootItem).
	var visit func(node typeNode, isRoot bool)
	visit = func(node typeNode, isRoot bool) {
		switch node.kind {
		case kindObject:
			if _, ok := names[node.sig]; !ok {
				candidate := node.nameHint
				if isRoot {
					candidate = rootName
				}
				if usedNames[candidate] {
					i := 2
					for usedNames[fmt.Sprintf("%s%d", candidate, i)] {
						i++
					}
					candidate = fmt.Sprintf("%s%d", candidate, i)
				}
				names[node.sig] = candidate
				usedNames[candidate] = true
				order = append(order, node)
				for _, p := range node.props {
					visit(p.typ, false)
				}
			} else {
				// already named — still recurse into newly-seen nested shapes
				for _, p := range node.props {
					visit(p.typ, false)
				}
			}
		case kindArray:
			if node.of != nil {
				visit(*node.of, false)
			}
		case kindUnion:
			for _, m := range node.members {
				visit(m, false)
			}
		}
	}
	visit(root, true)
	return order, names
}

// renderInterface renders an object node as a TS interface block.
func renderInterface(o typeNode, names map[string]string) string {
	name := names[o.sig]
	if len(o.props) == 0 {
		return fmt.Sprintf("interface %s {}", name)
	}
	lines := make([]string, len(o.props))
	for i, p := range o.props {
		opt := ""
		if p.optional {
			opt = "?"
		}
		lines[i] = fmt.Sprintf("  %s%s: %s;", renderKey(p.key), opt, renderType(p.typ, names))
	}
	return fmt.Sprintf("interface %s {\n%s\n}", name, strings.Join(lines, "\n"))
}

// JsonToTs infers TypeScript interfaces (or a type alias) from any
// JSON-serializable value. It is the Go twin of jsonToTs() in
// src/lib/json-to-typescript.ts and must agree with it on every shared vector.
//
// Object roots emit one or more interfaces; primitive / unknown / array roots
// emit a type alias. value may be a *jval from DecodeJSON or a raw Go JSON value
// (bool / number / string / nil / []any / map[string]any). Non-JSON-serializable
// values return an error (mirroring the TS lib's throw).
func JsonToTs(value any, opts Options) (string, error) {
	resolved := resolveOpts(opts)

	rootVal, err := toJval(value, "")
	if err != nil {
		return "", err
	}
	root := infer(rootVal, resolved.rootName, resolved)

	// Primitive / unknown / array roots emit a type alias; object roots emit
	// interfaces.
	if root.kind != kindObject {
		if root.kind == kindArray {
			order, names := collectObjects(root, resolved.rootName)
			parts := make([]string, len(order))
			for i, o := range order {
				parts[i] = renderInterface(o, names)
			}
			ifaces := strings.Join(parts, "\n\n")
			alias := fmt.Sprintf("type %s = %s;", resolved.rootName, renderType(root, names))
			if ifaces != "" {
				return ifaces + "\n\n" + alias, nil
			}
			return alias, nil
		}
		empty := map[string]string{}
		return fmt.Sprintf("type %s = %s;", resolved.rootName, renderType(root, empty)), nil
	}

	order, names := collectObjects(root, resolved.rootName)
	parts := make([]string, len(order))
	for i, o := range order {
		parts[i] = renderInterface(o, names)
	}
	return strings.Join(parts, "\n\n"), nil
}

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 →