JSON → TypeScript — Ruby 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 Ruby implementation — the same logic the interactive tool runs, in a shareable, citable form.
# json-to-typescript — infer TypeScript interfaces from any JSON value. Ruby
# port (canonical TS: src/lib/json-to-typescript/; Go twin: cli/json-to-typescript).
# Condensed core: primitives map to TS primitives, arrays merge element types
# (objects merge keys; keys absent from some element become optional), and
# shapes dedupe by structural signature into named interfaces. Full port with
# unionArrays/optionalNullable: python.py / javascript.js.
require 'json'
# abridged reserved list — the full ~60-word set lives in the python.py port
RESERVED = %w[class const default function interface let new null return this
true false type var boolean number string unknown].freeze
UNKNOWN = '?'
def pascal(key) # `user_id` -> `UserId`; leading digit escapes with N
parts = key.to_s.split(/[^A-Za-z0-9]+/).reject(&:empty?)
head = parts.empty? ? 'Item' : parts.map { |p| p[0].upcase + p[1..].to_s }.join
head.match?(/\A\d/) ? "N#{head}" : head
end
def singular(name) = name.size > 1 && name.end_with?('s') && !name.end_with?('ss') ? name[0..-2] : "#{name}Item"
# Type nodes: primitives are their TS name (or UNKNOWN); containers are hashes
# carrying a structural `sig` so equal shapes dedupe independent of names.
def sig_of(n)
return n if n.is_a?(String)
return n['sig'] if n['props']
return "(#{n['union'].map { |m| sig_of(m) }.uniq.join('|')})" if n['union']
n['arr'] ? "[#{sig_of(n['arr'])}]" : '[]'
end
def obj_node(props, hint)
{ 'props' => props, 'hint' => hint, 'sig' => "{#{props.map { |k, t, _| "#{k}:#{sig_of(t)}" }.join(';')}}" }
end
def infer(v, hint)
return 'null' if v.nil?
return 'boolean' if [true, false].include?(v)
return 'number' if v.is_a?(Numeric)
return 'string' if v.is_a?(String)
return { 'arr' => nil } if v.is_a?(Array) && v.empty?
return { 'arr' => combine(v.map { |e| infer(e, singular(hint)) }) } if v.is_a?(Array)
obj_node(v.map { |k, val| [k.to_s, infer(val, "#{hint}#{pascal(k)}"), false] }, hint)
end
def combine(nodes) # condensed: objects merge; the rest dedupe to single-or-union
return UNKNOWN if nodes.empty?
return merge_objects(nodes) if nodes.all? { |n| n.is_a?(Hash) && n['props'] }
d = nodes.uniq { |n| sig_of(n) }
d.size == 1 ? d[0] : { 'union' => d }
end
def merge_objects(objs) # union of keys; a key absent from some element becomes optional
keys = objs.flat_map { |o| o['props'].map(&:first) }.uniq
props = keys.map do |k|
types = objs.map { |o| o['props'].find { |p, _, _| p == k } }.compact.map { |p| p[1] }
[k, combine(types), types.size < objs.size]
end
obj_node(props, objs[0]['hint'])
end
def collect(node, names, order, forced = nil) # first-seen naming; root takes rootName; collisions get 2, 3, ...
if node.is_a?(Hash) && node['props']
unless names[node['sig']]
cand = forced || node['hint']
if names.value?(cand)
i = 2; i += 1 while names.value?("#{cand}#{i}"); cand = "#{cand}#{i}"
end
names[node['sig']] = cand; order << node
end
node['props'].each { |_, t, _| collect(t, names, order) }
elsif node.is_a?(Hash) && node['arr'] then collect(node['arr'], names, order)
elsif node.is_a?(Hash) && node['union'] then node['union'].each { |m| collect(m, names, order) }
end
end
def render_key(k) = k.match?(/\A[A-Za-z_$][A-Za-z0-9_$]*\z/) && !RESERVED.include?(k) ? k : k.to_json
def render_type(n, names)
return n == UNKNOWN ? 'unknown' : n if n.is_a?(String)
return names[n['sig']] || 'unknown' if n['props']
return n['union'].map { |m| render_type(m, names) }.join(' | ') if n['union']
inner = n['arr'] ? render_type(n['arr'], names) : 'unknown'
n['arr'].is_a?(Hash) && n['arr']['union'] ? "(#{inner})[]" : "#{inner}[]"
end
def json_to_ts(value, root_name = 'Root')
root = infer(value, pascal(root_name))
names = {}; order = []
collect(root, names, order, pascal(root_name))
ifaces = order.map do |o|
next "interface #{names[o['sig']]} {}" if o['props'].empty?
"interface #{names[o['sig']]} {\n" + o['props'].map { |k, t, opt| " #{render_key(k)}#{opt ? '?' : ''}: #{render_type(t, names)};" }.join("\n") + "\n}"
end.join("\n\n")
return ifaces if root.is_a?(Hash) && root['props']
alias_ = "type #{pascal(root_name)} = #{render_type(root, names)};" # non-object roots emit a type alias
ifaces.empty? ? alias_ : "#{ifaces}\n\n#{alias_}"
end
# shapes dedupe by signature; array objects merge keys (name?/active? optional)
puts json_to_ts(JSON.parse('{"users":[{"id":1,"name":"ada"},{"id":2,"active":true}]}'))
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 →