Writing a TypeScript Type Inference Engine in 300 Lines of Vanilla JS
A minimal JSON-to-TypeScript interface generator with multi-sample merging and type guard generation. Built to understand how the core of quicktype actually works.
Every time I get a new API endpoint, I do the same small dance: look at a response, eyeball the shape, write a TypeScript interface, copy-paste it into the codebase. quicktype exists and is excellent — but it's also a multi-language beast with a web app that's heavier than I need for this one job.
So I wrote a smaller version. Just JSON → TypeScript, in about 300 lines of vanilla JavaScript. No build step, no dependencies, runs entirely in the browser.
🔗 Live demo: https://sen.ltd/portfolio/json-to-ts/
📦 GitHub: https://github.com/sen-ltd/json-to-ts
Three things turned out to be interesting while building it, and I'll walk through them. They're all about the inference part — not the UI.
Part 1: A tiny AST makes the rest easy
Before anything, I defined the AST the tool uses internally. It's small enough to fit in one paragraph:
type TsType =
| { kind: 'primitive', name: 'string'|'number'|'boolean'|'null'|'undefined'|'any' }
| { kind: 'array', element: TsType }
| { kind: 'object', ref: string } // reference into interface list
| { kind: 'union', types: TsType[] }
type Interface = {
name: string,
fields: Array<{ key, type, optional, jsdoc? }>
}
That's it. No enums, no literal types, no generics, no intersection types. Keeping the AST narrow means every step downstream (inference, merging, rendering) is short and easy to test.
The one interesting choice: an object's value in the AST is a reference ({ kind: 'object', ref: 'User' }), not an inline structure. The actual interface definition lives in a separate list. This is what lets nested objects become their own named interfaces instead of one giant anonymous blob.
Part 2: mergeTypes is the whole trick
Most of the "smart" behavior — detecting optional fields, inferring unions, collapsing multiple API samples into one interface — comes from a single recursive function:
function mergeTypes(a, b, ctx) {
if (typesEqual(a, b)) return a
// Two different primitives → union
if (a.kind === 'primitive' && b.kind === 'primitive') {
return { kind: 'union', types: [a, b] }
}
// Two arrays → merge elements
if (a.kind === 'array' && b.kind === 'array') {
return { kind: 'array', element: mergeTypes(a.element, b.element, ctx) }
}
// Two object refs → merge their interfaces
if (a.kind === 'object' && b.kind === 'object') {
mergeInterfacesInPlace(a.ref, b.ref, ctx)
return a
}
// Mixed kinds → union
return { kind: 'union', types: [a, b] }
}
Once you have mergeTypes, everything else falls out for free:
-
Array elements: take the first element, then
mergeTypesit with every other element in turn. If they're all the same you get one type. If they're mixed, you get a union. - Multiple samples: treat the user's list of JSON samples as if it were an array of the root type, and apply the same loop. Done.
-
Optional field detection: happens inside
mergeInterfacesInPlace. If a key exists in one interface but not the other, the merged version marks it optional.
That last bit is the piece that makes multi-sample merging feel like magic:
function mergeInterfacesInPlace(aName, bName, ctx) {
const a = ctx.interfaces.find(i => i.name === aName)
const b = ctx.interfaces.find(i => i.name === bName)
const allKeys = new Set([...a.fields.map(f => f.key), ...b.fields.map(f => f.key)])
const merged = []
for (const key of allKeys) {
const fa = a.fields.find(f => f.key === key)
const fb = b.fields.find(f => f.key === key)
if (fa && fb) {
merged.push({ key, type: mergeTypes(fa.type, fb.type, ctx), optional: fa.optional || fb.optional })
} else {
// Only in one → optional in the merge
merged.push({ ...(fa || fb), optional: true })
}
}
a.fields = merged
ctx.interfaces.splice(ctx.interfaces.indexOf(b), 1)
}
Paste these two samples:
{"id":1,"name":"A","email":"a@x"}{"id":2,"name":"B","age":30}
…and you get:
export interface Root {
id: number
name: string
email?: string
age?: number
}
email and age are each only in one sample, so they end up optional. id and name are in both so they stay required. No extra code for this behavior — it just falls out of mergeTypes + mergeInterfaces.
Part 3: Generating type guards from the same AST
This is the feature that makes me use my own tool instead of copy-pasting from an online one. From the same AST I emit type guard functions:
export function isRoot(obj: unknown): obj is Root {
if (typeof obj !== 'object' || obj === null) return false
const o = obj as Record<string, unknown>
if (typeof o.id !== 'number') return false
if (typeof o.name !== 'string') return false
if (o.email !== undefined && !(typeof o.email === 'string')) return false
if (o.age !== undefined && !(typeof o.age === 'number')) return false
return true
}
The generator walks the same AST as the interface generator but emits runtime checks instead of type annotations:
function renderTypeCheck(type, expr) {
if (type.kind === 'primitive') {
if (['string', 'number', 'boolean'].includes(type.name)) {
return `typeof ${expr} === '${type.name}'`
}
if (type.name === 'null') return `${expr} === null`
}
if (type.kind === 'array') {
const inner = renderTypeCheck(type.element, '__e')
return `Array.isArray(${expr}) && (${expr} as unknown[]).every((__e) => ${inner})`
}
if (type.kind === 'object') {
return `is${type.ref}(${expr})` // call sibling guard
}
if (type.kind === 'union') {
return type.types.map(t => `(${renderTypeCheck(t, expr)})`).join(' || ')
}
}
Nested objects just call the sibling guard (isAddress(o.address)), which is recursive but stays flat because each interface gets its own named function. For optional fields I prepend an undefined check so the guard doesn't reject correctly-missing keys.
This matters because as User is a lie. At runtime you have an unknown that you're asking the compiler to trust. A type guard turns that lie into a check. For API boundary code I'd rather pay the few extra lines and know the shape actually matches.
The rest
-
parser.js— 15 lines, wrapsJSON.parseinto an{ ok, value, error }result object. -
generator.js— 60 lines, stringifies the AST into TypeScript source. The only trick is wrapping unions inside array types in parens:(string | number)[]notstring | number[]. -
main.js— the DOM glue. Three components: JSON textareas (one per sample), live-updating output pane, URL query sync. Debounced at 200ms so typing doesn't feel laggy. -
tests/— 44 test cases onnode --test, no dependencies. Mostly the AST tests; each mergeTypes edge case is one short assertion.
Why rebuild something that already exists
quicktype is ~40,000 lines of TypeScript, supports many target languages, and deals with a huge number of edge cases I'll never hit. This tool is 300 lines and only does TypeScript. It fits in my head.
For a portfolio project specifically — where the point is to show what you can build in a weekend and to teach something in a blog post — "minimal and readable" beats "feature-complete" every time. The code in this article is basically the whole thing. Nothing hidden behind abstractions I don't show.
Closing
This is entry #2 in a 100+ portfolio series by SEN LLC. Previous entry: Cron TZ Viewer and its article. Same spirit: build small, ship fast, write about the interesting bit.
Feedback, bug reports, gnarly JSON samples that break it — all welcome.