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

Collapse
X
 
  • Time
  • Show
Clear All
new posts
  • MyrinNew
    Senior Member
    • Feb 2024
    • 5175

    #1

    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





    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'|'an y' }
    | { 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 Recordstring, 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.




    More...
Working...