Skip to content

JSON → TypeScript — Rust 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 Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.

// =============================================================================
// json-to-typescript — Rust port
// =============================================================================
// Infer a TypeScript interface tree from any JSON-serializable value.
//
// CosmoDev polyglot showcase port of the `json-to-typescript` tool.
// Ported from src/lib/json-to-typescript.ts (the canonical TypeScript lib).
//
// Pure, deterministic, stdlib only. Object values become named interfaces
// (deduplicated by structural shape); arrays become `T[]`; primitives map to
// TS primitives; literal `null` becomes `null`. See `json_to_ts`.
//
// This is display source — part of CosmoDev's polyglot tool pages.
// =============================================================================

use std::collections::{HashMap, HashSet};

// ---------------------------------------------------------------------------
// Options (mirror the TS lib exactly)
// ---------------------------------------------------------------------------

#[derive(Clone)]
pub struct Options {
    /// Name of the root interface/type. Default `"Root"`.
    pub root_name: String,
    /// When true, emit a true union of distinct array element types instead of
    /// merging them.
    pub union_arrays: bool,
    /// When true, object properties whose type includes `null` become optional.
    pub optional_nullable: bool,
}

impl Default for Options {
    fn default() -> Self {
        Options {
            root_name: "Root".to_string(),
            union_arrays: false,
            optional_nullable: false,
        }
    }
}

fn resolve(opts: &Options) -> Options {
    let root_name = if opts.root_name.is_empty() {
        "Root"
    } else {
        &opts.root_name
    };
    Options {
        root_name: sanitize_root(root_name),
        union_arrays: opts.union_arrays,
        optional_nullable: opts.optional_nullable,
    }
}

// ---------------------------------------------------------------------------
// JSON value tree (the input)
// ---------------------------------------------------------------------------
// Rust's stdlib has no JSON support, so this file is self-contained: a tiny
// `Value` enum plus a minimal parser. `Object` keeps its pairs in a `Vec` so
// key order — which is semantically observable in the emitted interface — is
// preserved.

#[derive(Clone, Debug)]
pub enum Value {
    Null,
    Bool(bool),
    // JSON numbers map to TS `number` regardless of integral-ness.
    Number(f64),
    Str(String),
    Array(Vec<Value>),
    Object(Vec<(String, Value)>),
}

// ---------------------------------------------------------------------------
// Minimal JSON parser
// ---------------------------------------------------------------------------
// Compact recursive-descent parser. Sufficient for any RFC 8259 document a
// caller is likely to feed this tool.

type PResult<T> = Result<T, String>;

struct Parser<'a> {
    bytes: &'a [u8],
    pos: usize,
}

impl<'a> Parser<'a> {
    fn new(input: &'a str) -> Self {
        Parser {
            bytes: input.as_bytes(),
            pos: 0,
        }
    }

    fn skip_ws(&mut self) {
        while self.pos < self.bytes.len() {
            match self.bytes[self.pos] {
                b' ' | b'\t' | b'\n' | b'\r' => self.pos += 1,
                _ => break,
            }
        }
    }

    fn peek(&self) -> Option<u8> {
        self.bytes.get(self.pos).copied()
    }

    fn parse_value(&mut self) -> PResult<Value> {
        self.skip_ws();
        match self.peek().ok_or_else(|| "unexpected end of input".to_string())? {
            b'{' => self.parse_object(),
            b'[' => self.parse_array(),
            b'"' => Ok(Value::Str(self.parse_string()?)),
            b't' | b'f' => self.parse_bool(),
            b'n' => self.parse_null(),
            b'-' | b'0'..=b'9' => self.parse_number(),
            c => Err(format!("unexpected character {:?}", c as char)),
        }
    }

    fn parse_object(&mut self) -> PResult<Value> {
        self.pos += 1; // {
        let mut pairs = Vec::new();
        self.skip_ws();
        if self.peek() == Some(b'}') {
            self.pos += 1;
            return Ok(Value::Object(pairs));
        }
        loop {
            self.skip_ws();
            if self.peek() != Some(b'"') {
                return Err("expected string key in object".to_string());
            }
            let key = self.parse_string()?;
            self.skip_ws();
            if self.peek() != Some(b':') {
                return Err("expected ':' after object key".to_string());
            }
            self.pos += 1;
            let val = self.parse_value()?;
            pairs.push((key, val));
            self.skip_ws();
            match self.peek() {
                Some(b',') => {
                    self.pos += 1;
                }
                Some(b'}') => {
                    self.pos += 1;
                    break;
                }
                _ => return Err("expected ',' or '}' in object".to_string()),
            }
        }
        Ok(Value::Object(pairs))
    }

    fn parse_array(&mut self) -> PResult<Value> {
        self.pos += 1; // [
        let mut items = Vec::new();
        self.skip_ws();
        if self.peek() == Some(b']') {
            self.pos += 1;
            return Ok(Value::Array(items));
        }
        loop {
            let val = self.parse_value()?;
            items.push(val);
            self.skip_ws();
            match self.peek() {
                Some(b',') => {
                    self.pos += 1;
                }
                Some(b']') => {
                    self.pos += 1;
                    break;
                }
                _ => return Err("expected ',' or ']' in array".to_string()),
            }
        }
        Ok(Value::Array(items))
    }

    fn parse_string(&mut self) -> PResult<String> {
        self.pos += 1; // opening quote
        let mut out = String::new();
        while let Some(c) = self.peek() {
            self.pos += 1;
            match c {
                b'"' => return Ok(out),
                b'\\' => {
                    let e = self.peek().ok_or_else(|| "trailing escape".to_string())?;
                    self.pos += 1;
                    match e {
                        b'"' => out.push('"'),
                        b'\\' => out.push('\\'),
                        b'/' => out.push('/'),
                        b'n' => out.push('\n'),
                        b't' => out.push('\t'),
                        b'r' => out.push('\r'),
                        b'b' => out.push('\u{0008}'),
                        b'f' => out.push('\u{000C}'),
                        b'u' => {
                            let cp = self.parse_codepoint()?;
                            out.push(cp);
                        }
                        _ => return Err(format!("bad escape \\{}", e as char)),
                    }
                }
                _ => out.push(c as char),
            }
        }
        Err("unterminated string".to_string())
    }

    fn parse_codepoint(&mut self) -> PResult<char> {
        if self.pos + 4 > self.bytes.len() {
            return Err("short \\u escape".to_string());
        }
        let hex = std::str::from_utf8(&self.bytes[self.pos..self.pos + 4])
            .map_err(|_| "non-ascii in \\u escape".to_string())?;
        self.pos += 4;
        let mut code =
            u32::from_str_radix(hex, 16).map_err(|_| "bad \\u escape".to_string())?;
        // UTF-16 surrogate pair handling.
        if (0xD800..=0xDBFF).contains(&code) {
            if self.bytes[self.pos..].starts_with(b"\\u") {
                self.pos += 2;
                let lo_hex = std::str::from_utf8(&self.bytes[self.pos..self.pos + 4])
                    .map_err(|_| "non-ascii in low surrogate".to_string())?;
                self.pos += 4;
                let lo = u32::from_str_radix(lo_hex, 16)
                    .map_err(|_| "bad low surrogate".to_string())?;
                if (0xDC00..=0xDFFF).contains(&lo) {
                    code = 0x10000 + ((code - 0xD800) << 10) + (lo - 0xDC00);
                }
            }
        }
        char::from_u32(code).ok_or_else(|| "invalid unicode codepoint".to_string())
    }

    fn parse_bool(&mut self) -> PResult<Value> {
        if self.bytes[self.pos..].starts_with(b"true") {
            self.pos += 4;
            Ok(Value::Bool(true))
        } else if self.bytes[self.pos..].starts_with(b"false") {
            self.pos += 5;
            Ok(Value::Bool(false))
        } else {
            Err("invalid literal".to_string())
        }
    }

    fn parse_null(&mut self) -> PResult<Value> {
        if self.bytes[self.pos..].starts_with(b"null") {
            self.pos += 4;
            Ok(Value::Null)
        } else {
            Err("invalid literal".to_string())
        }
    }

    fn parse_number(&mut self) -> PResult<Value> {
        let start = self.pos;
        if self.peek() == Some(b'-') {
            self.pos += 1;
        }
        while let Some(c) = self.peek() {
            match c {
                b'0'..=b'9' | b'.' | b'e' | b'E' | b'+' | b'-' => self.pos += 1,
                _ => break,
            }
        }
        let s = std::str::from_utf8(&self.bytes[start..self.pos])
            .map_err(|_| "non-utf8 number".to_string())?;
        let n: f64 = s.parse().map_err(|_| format!("bad number {}", s))?;
        Ok(Value::Number(n))
    }
}

/// Parse a JSON document into a [Value]. This is the entry point for callers
/// holding a raw JSON string.
pub fn parse_json(input: &str) -> PResult<Value> {
    let mut p = Parser::new(input);
    let v = p.parse_value()?;
    p.skip_ws();
    if p.pos != p.bytes.len() {
        return Err(format!("trailing data at byte {}", p.pos));
    }
    Ok(v)
}

// ---------------------------------------------------------------------------
// Type tree
// ---------------------------------------------------------------------------
// A recursive enum describing an inferred TS type. `signature` (below) is a
// structural fingerprint used to dedupe identical object shapes independent of
// the names we eventually assign.

#[derive(Clone)]
enum TypeNode {
    Primitive(String),
    Unknown,
    // `None` element type means `unknown[]`.
    Array(Option<Box<TypeNode>>),
    Union(Vec<TypeNode>),
    Object {
        props: Vec<PropNode>,
        name_hint: String,
    },
}

#[derive(Clone)]
struct PropNode {
    key: String,
    typ: TypeNode,
    optional: bool,
}

fn null_node() -> TypeNode {
    TypeNode::Primitive("null".to_string())
}

/// A structural fingerprint of a type, independent of assigned names. Object
/// signatures include each key (with optionality) and recurse, so two objects
/// with the same shape always share a signature.
fn signature(n: &TypeNode) -> String {
    match n {
        TypeNode::Primitive(s) => s.clone(),
        TypeNode::Unknown => "?".to_string(),
        TypeNode::Array(of) => match of {
            Some(inner) => format!("[{}]", signature(inner)),
            None => "[]".to_string(),
        },
        TypeNode::Union(members) => {
            let mut parts: Vec<String> = members.iter().map(signature).collect();
            let mut joined = parts.join("|");
            joined.insert(0, '(');
            joined.push(')');
            joined
        }
        TypeNode::Object { props, .. } => {
            let body: String = props
                .iter()
                .map(|p| {
                    let opt = if p.optional { "?" } else { "" };
                    format!("{}{}:{}", p.key, opt, signature(&p.typ))
                })
                .collect::<Vec<_>>()
                .join(";");
            format!("{{{}}}", body)
        }
    }
}

// ---------------------------------------------------------------------------
// Small text helpers
// ---------------------------------------------------------------------------

/// PascalCase a key segment for use in an interface name (`user_id` ->
/// `UserId`). Empty input collapses to `Item`; a leading digit is escaped with
/// `N` so the result is a valid TS identifier.
fn pascal(key: &str) -> String {
    let segs: Vec<&str> = key.split(|c: char| !c.is_ascii_alphanumeric()).filter(|s| !s.is_empty()).collect();
    let head = if segs.is_empty() {
        "Item".to_string()
    } else {
        let mut b = String::new();
        for s in &segs {
            let mut chars = s.chars();
            if let Some(first) = chars.next() {
                for c in first.to_uppercase() {
                    b.push(c);
                }
                b.push_str(chars.as_str());
            }
        }
        b
    };
    if head.starts_with(|c: char| c.is_ascii_digit()) {
        format!("N{}", head)
    } else {
        head
    }
}

/// Singularize an interface name for array-element naming (`Items` -> `Item`).
/// Trims a trailing `s` (never `ss`); otherwise appends `Item`.
fn singularize(name: &str) -> String {
    let n = name.len();
    if n > 1 && name.ends_with('s') && !name.ends_with("ss") {
        name[..n - 1].to_string()
    } else {
        format!("{}Item", name)
    }
}

/// Coerce a user-supplied root name into a valid TS identifier (PascalCase).
fn sanitize_root(name: &str) -> String {
    let cleaned = pascal(name);
    if cleaned.is_empty() {
        "Root".to_string()
    } else {
        cleaned
    }
}

// ---------------------------------------------------------------------------
// Input validation
// ---------------------------------------------------------------------------
// Because `Value` is a sealed enum of exactly the JSON kinds, every input is
// JSON-serializable by construction — the runtime check the TS lib performs is
// a compile-time guarantee here. The function is kept for API parity.

fn assert_json_serializable(_v: &Value, _path: &str) -> Result<(), String> {
    Ok(())
}

// ---------------------------------------------------------------------------
// Shape helpers
// ---------------------------------------------------------------------------

/// Does a type contain a `null` leaf? Used by `optional_nullable`.
fn contains_null(n: &TypeNode) -> bool {
    match n {
        TypeNode::Primitive(s) => s == "null",
        TypeNode::Union(members) => members.iter().any(contains_null),
        _ => false,
    }
}

/// Dedupe nodes by structural signature, preserving first-seen order.
fn dedupe(nodes: &[TypeNode]) -> Vec<TypeNode> {
    let mut seen: HashSet<String> = HashSet::new();
    let mut out: Vec<TypeNode> = Vec::new();
    for n in nodes {
        let s = signature(n);
        if seen.insert(s) {
            out.push(n.clone());
        }
    }
    out
}

// ---------------------------------------------------------------------------
// Combining array element types
// ---------------------------------------------------------------------------

/// Merge several object nodes into one: union of keys (keys absent from some
/// element become optional), recursing per key.
fn merge_objects(objs: &[TypeNode], opts: &Options) -> TypeNode {
    // We know every entry is an Object; extract (props, name_hint) pairs.
    let mut key_order: Vec<String> = Vec::new();
    let mut by_key: HashMap<String, Vec<TypeNode>> = HashMap::new();
    let first_hint = if let TypeNode::Object { name_hint, .. } = &objs[0] {
        name_hint.clone()
    } else {
        String::new()
    };

    for o in objs {
        if let TypeNode::Object { props, .. } = o {
            for p in props {
                if !by_key.contains_key(&p.key) {
                    key_order.push(p.key.clone());
                }
                by_key.entry(p.key.clone()).or_default().push(p.typ.clone());
            }
        }
    }

    let total = objs.len();
    let mut props: Vec<PropNode> = Vec::with_capacity(key_order.len());
    for key in key_order {
        let child_types = by_key.remove(&key).unwrap_or_default();
        let typ = combine(&child_types, opts);
        let mut optional = child_types.len() < total; // missing from some element
        if opts.optional_nullable && contains_null(&typ) {
            optional = true;
        }
        props.push(PropNode {
            key: key.clone(),
            typ,
            optional,
        });
    }
    TypeNode::Object {
        props,
        name_hint: first_hint,
    }
}

/// Combine a list of element types into one. empty -> unknown; `union_arrays`
/// -> distinct union; otherwise merge where sensible (objects merge keys,
/// distinct primitives union) and build a union only for genuinely
/// heterogeneous input.
fn combine(nodes: &[TypeNode], opts: &Options) -> TypeNode {
    if nodes.is_empty() {
        return TypeNode::Unknown;
    }
    if opts.union_arrays {
        let d = dedupe(nodes);
        return if d.len() == 1 {
            d.into_iter().next().unwrap()
        } else {
            TypeNode::Union(d)
        };
    }

    let mut objs: Vec<&TypeNode> = Vec::new();
    let mut arrs: Vec<&TypeNode> = Vec::new();
    let mut prims: Vec<TypeNode> = Vec::new();
    let mut has_unknown = false;
    for n in nodes {
        match n {
            TypeNode::Object { .. } => objs.push(n),
            TypeNode::Array(_) => arrs.push(n),
            TypeNode::Primitive(_) => prims.push(n.clone()),
            TypeNode::Unknown => has_unknown = true,
            // A Union fed back into combine (non-union mode) matches none of
            // the TS filters and is dropped — preserved here for parity.
            TypeNode::Union(_) => {}
        }
    }
    let prims = dedupe(&prims);

    // Pure primitive/unknown arrays collapse: identical -> single, distinct -> union.
    if objs.is_empty() && arrs.is_empty() {
        let mut members = prims.clone();
        if has_unknown {
            members.push(TypeNode::Unknown);
        }
        let d = dedupe(&members);
        return if d.len() == 1 {
            d.into_iter().next().unwrap()
        } else {
            TypeNode::Union(d)
        };
    }

    // Homogeneous object array -> a single merged object.
    if !objs.is_empty()
        && arrs.is_empty()
        && prims.is_empty()
        && !has_unknown
    {
        let owned: Vec<TypeNode> = objs.iter().cloned().collect();
        return merge_objects(&owned, opts);
    }

    // Otherwise build a union of the meaningful parts.
    let mut members: Vec<TypeNode> = Vec::new();
    if !objs.is_empty() {
        let owned: Vec<TypeNode> = objs.iter().cloned().collect();
        members.push(merge_objects(&owned, opts));
    }
    if !arrs.is_empty() {
        let mut of_types: Vec<TypeNode> = Vec::new();
        for a in &arrs {
            if let TypeNode::Array(Some(of)) = a {
                of_types.push((**of).clone());
            } else {
                of_types.push(TypeNode::Unknown);
            }
        }
        let of = if of_types.is_empty() {
            None
        } else {
            Some(Box::new(combine(&of_types, opts)))
        };
        members.push(TypeNode::Array(of));
    }
    members.extend(prims.iter().cloned());
    if has_unknown {
        members.push(TypeNode::Unknown);
    }
    let d = dedupe(&members);
    if d.len() == 1 {
        d.into_iter().next().unwrap()
    } else {
        TypeNode::Union(d)
    }
}

// ---------------------------------------------------------------------------
// Inference
// ---------------------------------------------------------------------------

/// Recursively infer a type tree from a JSON value. `hint` is the interface
/// name to use if this value is an object.
fn infer(value: &Value, hint: &str, opts: &Options) -> TypeNode {
    match value {
        Value::Null => null_node(),
        Value::Bool(_) => TypeNode::Primitive("boolean".to_string()),
        Value::Number(_) => TypeNode::Primitive("number".to_string()),
        Value::Str(_) => TypeNode::Primitive("string".to_string()),
        Value::Array(items) => {
            if items.is_empty() {
                return TypeNode::Array(None);
            }
            let elem_hint = singularize(hint);
            let elements: Vec<TypeNode> =
                items.iter().map(|e| infer(e, &elem_hint, opts)).collect();
            TypeNode::Array(Some(Box::new(combine(&elements, opts))))
        }
        Value::Object(pairs) => {
            // plain object (serializability guaranteed by the Value type)
            let mut props: Vec<PropNode> = Vec::with_capacity(pairs.len());
            for (key, v) in pairs {
                let child_hint = format!("{}{}", hint, pascal(key));
                let typ = infer(v, &child_hint, opts);
                let optional = opts.optional_nullable && contains_null(&typ);
                props.push(PropNode {
                    key: key.clone(),
                    typ,
                    optional,
                });
            }
            TypeNode::Object {
                props,
                name_hint: hint.to_string(),
            }
        }
    }
}

// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------

/// TypeScript reserved words + built-in type names. Any object key appearing
/// here (or not a bareword identifier) must be emitted as a quoted string key.
const RESERVED: &[&str] = &[
    "break", "case", "catch", "class", "const", "continue", "debugger", "default",
    "delete", "do", "else", "enum", "export", "extends", "false", "finally", "for",
    "function", "if", "import", "in", "instanceof", "new", "null", "return", "super",
    "switch", "this", "throw", "true", "try", "typeof", "var", "void", "while", "with",
    "as", "async", "await", "yield", "let", "static", "implements", "interface",
    "package", "private", "protected", "public", "type", "readonly", "namespace",
    "abstract", "any", "boolean", "never", "number", "object", "string", "symbol",
    "undefined", "unknown", "keyof", "infer", "satisfies",
];

fn is_bare_ident(s: &str) -> bool {
    let mut chars = s.chars();
    match chars.next() {
        Some(c) if c == '_' || c == '$' || c.is_ascii_alphabetic() => {}
        _ => return false,
    }
    chars.all(|c| c == '_' || c == '$' || c.is_ascii_alphanumeric())
}

/// Quote-and-escape a key as a JSON string literal.
fn quote_key(key: &str) -> String {
    let mut out = String::with_capacity(key.len() + 2);
    out.push('"');
    for c in key.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\t' => out.push_str("\\t"),
            '\r' => out.push_str("\\r"),
            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
            c => out.push(c),
        }
    }
    out.push('"');
    out
}

/// Emit a bareword key when legal, else a quoted JSON string literal.
fn render_key(key: &str) -> String {
    if is_bare_ident(key) && !RESERVED.contains(&key) {
        key.to_string()
    } else {
        quote_key(key)
    }
}

/// Wrap an array element in parens if it would otherwise mis-parse (a union).
fn wrap_array(rendered: String, of: &TypeNode) -> String {
    match of {
        TypeNode::Union(_) => format!("({})", rendered),
        _ => rendered,
    }
}

fn render_type(n: &TypeNode, names: &HashMap<String, String>) -> String {
    match n {
        TypeNode::Primitive(s) => s.clone(),
        TypeNode::Unknown => "unknown".to_string(),
        TypeNode::Object { .. } => names
            .get(&signature(n))
            .cloned()
            .unwrap_or_else(|| "unknown".to_string()),
        TypeNode::Array(of) => match of {
            None => "unknown[]".to_string(),
            Some(inner) => format!("{}[]", wrap_array(render_type(inner, names), inner)),
        },
        TypeNode::Union(members) => dedupe(members)
            .iter()
            .map(|m| render_type(m, names))
            .collect::<Vec<_>>()
            .join(" | "),
    }
}

/// Collect 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. Only the actual root node takes
/// `root_name`; an array-of-objects root names its element `<Root>Item`.
fn collect_objects(root: &TypeNode, root_name: &str) -> (Vec<TypeNode>, HashMap<String, String>) {
    let mut names: HashMap<String, String> = HashMap::new();
    let mut used: HashSet<String> = HashSet::new();
    let mut order: Vec<TypeNode> = Vec::new();

    fn push_prop_types(props: &[PropNode]) -> Vec<TypeNode> {
        props.iter().map(|p| p.typ.clone()).collect()
    }

    fn visit(
        n: &TypeNode,
        is_root: bool,
        root_name: &str,
        names: &mut HashMap<String, String>,
        used: &mut HashSet<String>,
        order: &mut Vec<TypeNode>,
    ) {
        if let TypeNode::Object { props, name_hint } = n {
            let sig = signature(n);
            if !names.contains_key(&sig) {
                let mut candidate = if is_root {
                    root_name.to_string()
                } else {
                    name_hint.clone()
                };
                if used.contains(&candidate) {
                    let mut i = 2;
                    while used.contains(&format!("{}{}", candidate, i)) {
                        i += 1;
                    }
                    candidate = format!("{}{}", candidate, i);
                }
                names.insert(sig.clone(), candidate.clone());
                used.insert(candidate);
                order.push(n.clone());
                for t in push_prop_types(props) {
                    visit(&t, false, root_name, names, used, order);
                }
            } else {
                // already named — still recurse to discover new nested shapes
                for t in push_prop_types(props) {
                    visit(&t, false, root_name, names, used, order);
                }
            }
        } else if let TypeNode::Array(Some(of)) = n {
            visit(of, false, root_name, names, used, order);
        } else if let TypeNode::Union(members) = n {
            for m in members {
                visit(m, false, root_name, names, used, order);
            }
        }
    }

    visit(root, true, root_name, &mut names, &mut used, &mut order);
    (order, names)
}

fn render_interface(n: &TypeNode, names: &HashMap<String, String>) -> String {
    if let TypeNode::Object { props, .. } = n {
        let name = names.get(&signature(n)).cloned().unwrap_or_default();
        if props.is_empty() {
            return format!("interface {} {{}}", name);
        }
        let lines: Vec<String> = props
            .iter()
            .map(|p| {
                let opt = if p.optional { "?" } else { "" };
                format!(
                    "  {}{}: {};",
                    render_key(&p.key),
                    opt,
                    render_type(&p.typ, names)
                )
            })
            .collect();
        format!("interface {} {{\n{}\n}}", name, lines.join("\n"))
    } else {
        String::new()
    }
}

// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------

/// Infer TypeScript interfaces from any JSON-serializable value.
///
/// Returns the emitted source directly: because `Value` guarantees
/// JSON-serializability at compile time, inference cannot fail at runtime.
pub fn json_to_ts(value: &Value, opts: &Options) -> String {
    let _ = assert_json_serializable(value, ""); // parity no-op; see note above
    let resolved = resolve(opts);
    let root = infer(value, &resolved.root_name, &resolved);

    // Primitive / unknown / array roots emit a `type` alias; object roots emit interfaces.
    match &root {
        TypeNode::Object { .. } => {
            let (order, names) = collect_objects(&root, &resolved.root_name);
            order
                .iter()
                .map(|o| render_interface(o, &names))
                .collect::<Vec<_>>()
                .join("\n\n")
        }
        TypeNode::Array(_) => {
            let (order, names) = collect_objects(&root, &resolved.root_name);
            let ifaces = order
                .iter()
                .map(|o| render_interface(o, &names))
                .collect::<Vec<_>>()
                .join("\n\n");
            let alias = format!(
                "type {} = {};",
                resolved.root_name,
                render_type(&root, &names)
            );
            if ifaces.is_empty() {
                alias
            } else {
                format!("{}\n\n{}", ifaces, alias)
            }
        }
        // primitive or unknown root
        _ => format!(
            "type {} = {};",
            resolved.root_name,
            render_type(&root, &HashMap::new())
        ),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn simple_object() {
        let v = parse_json(r#"{"name":"a","age":1}"#).unwrap();
        let out = json_to_ts(&v, &Options::default());
        assert_eq!(out, "interface Root {\n  name: string;\n  age: number;\n}");
    }
}

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 →