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 →