Writing a TypeScript Type Inference Engine in 300 Lines of Vanilla JS

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

Screenshot

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 mergeTypes it 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, wraps JSON.parse into 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)[] not string | 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 on node --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.

Leave a Reply