DEV Community

SEN LLC
SEN LLC

Posted on

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? }>
}
Enter fullscreen mode Exit fullscreen mode

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] }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

Paste these two samples:

{"id": 1, "name": "A", "email": "a@x"}
{"id": 2, "name": "B", "age": 30}
Enter fullscreen mode Exit fullscreen mode

…and you get:

export interface Root {
  id: number
  name: string
  email?: string
  age?: number
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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(' || ')
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)