<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Leyman blog</title>
    <link>https://jacobley.github.io/leyman/blog/</link>
    <atom:link href="https://jacobley.github.io/leyman/blog/feed.xml" rel="self" type="application/rss+xml"/>
    <description>Benchmarks, comparisons and stories behind the Leyman TypeScript packages.</description>
    <language>en</language>
    <item>
      <title>How narrow are generated TypeScript types? Six JSON Schema generators compared</title>
      <link>https://jacobley.github.io/leyman/blog/json-schema-to-typescript-compared</link>
      <guid isPermaLink="true">https://jacobley.github.io/leyman/blog/json-schema-to-typescript-compared</guid>
      <pubDate>Sat, 07 Nov 2026 00:00:00 GMT</pubDate>
      <description>genever, json-schema-to-typescript, quicktype, json-schema-to-zod, json-schema-to-ts and TypeBox, given the same schema every night, with each generated type checked against valid values, invalid values and reads.</description>
      <content:encoded><![CDATA[<p>A JSON Schema can say more than most generated TypeScript types do. A tuple of two numbers becomes <code>number[]</code>, an ID that must start with <code>ord_</code> becomes <code>string</code>, and an object that only allows <code>x-</code> headers takes any key. The type compiles, and bad data goes straight through it.</p>
<p>My existing package, <a href="https://jacobley.github.io/leyman/packages/juniper/">Juniper</a>, avoids this by building the schema in TypeScript at runtime, so its types come straight from the code. That leaves a gap: a schema from somewhere else, like another service's OpenAPI spec or a shared JSON file, can't be <em>safely</em> pulled into TypeScript that way.</p>
<p>I wrote <a href="https://jacobley.github.io/leyman/packages/genever/">genever</a> for those schemas. It reads the file and writes types narrow enough that the bad data above is a compile error. To check that it really is narrower than the alternatives, and stays that way, a nightly job gives it and five other generators the same schema and type-checks what each one produces.</p>
<h2 id="the-same-schema-two-outputs" tabindex="-1">The same schema, two outputs</h2>
<p>A schema for an order, with one property per feature that TypeScript can express but most generators don't:</p>
<pre><code class="language-json">{
  &quot;$schema&quot;: &quot;https://json-schema.org/draft/2020-12/schema&quot;,
  &quot;type&quot;: &quot;object&quot;,
  &quot;properties&quot;: {
    &quot;id&quot;: { &quot;type&quot;: &quot;string&quot;, &quot;pattern&quot;: &quot;^ord_&quot; },
    &quot;location&quot;: {
      &quot;type&quot;: &quot;array&quot;,
      &quot;prefixItems&quot;: [{ &quot;type&quot;: &quot;number&quot; }, { &quot;type&quot;: &quot;number&quot; }],
      &quot;items&quot;: false,
      &quot;minItems&quot;: 2
    },
    &quot;shipping&quot;: {
      &quot;oneOf&quot;: [
        {
          &quot;type&quot;: &quot;object&quot;,
          &quot;properties&quot;: { &quot;kind&quot;: { &quot;const&quot;: &quot;pickup&quot; }, &quot;store&quot;: { &quot;type&quot;: &quot;string&quot; } },
          &quot;required&quot;: [&quot;kind&quot;, &quot;store&quot;],
          &quot;additionalProperties&quot;: false
        },
        {
          &quot;type&quot;: &quot;object&quot;,
          &quot;properties&quot;: { &quot;kind&quot;: { &quot;const&quot;: &quot;delivery&quot; }, &quot;address&quot;: { &quot;type&quot;: &quot;string&quot; } },
          &quot;required&quot;: [&quot;kind&quot;, &quot;address&quot;],
          &quot;additionalProperties&quot;: false
        }
      ]
    },
    &quot;headers&quot;: {
      &quot;type&quot;: &quot;object&quot;,
      &quot;patternProperties&quot;: { &quot;^x-&quot;: { &quot;type&quot;: &quot;string&quot; } },
      &quot;additionalProperties&quot;: false
    },
    &quot;payment&quot;: {
      &quot;type&quot;: &quot;object&quot;,
      &quot;properties&quot;: { &quot;card&quot;: { &quot;type&quot;: &quot;string&quot; }, &quot;billingAddress&quot;: { &quot;type&quot;: &quot;string&quot; } },
      &quot;dependentRequired&quot;: { &quot;card&quot;: [&quot;billingAddress&quot;] },
      &quot;additionalProperties&quot;: false
    }
  },
  &quot;required&quot;: [&quot;id&quot;],
  &quot;additionalProperties&quot;: false
}
</code></pre>
<p><a href="https://www.npmjs.com/package/json-schema-to-typescript" target="_blank" rel="noreferrer">json-schema-to-typescript</a> 16.0.0, the most-downloaded tool that writes a <code>.ts</code> file from JSON Schema, generates this (banner comment removed):</p>
<pre><code class="language-ts">export interface Order {
  id: string;
  /**
   * @minItems 2
   */
  location?: never[];
  shipping?:
    | {
        kind: &quot;pickup&quot;;
        store: string;
      }
    | {
        kind: &quot;delivery&quot;;
        address: string;
      };
  headers?: {
    /**
     * This interface was referenced by `undefined`'s JSON-Schema definition
     * via the `patternProperty` &quot;^x-&quot;.
     */
    [k: string]: string;
  };
  payment?: {
    card?: string;
    billingAddress?: string;
  };
}
</code></pre>
<p>The <code>oneOf</code> union is right. <code>location</code> is <code>never[]</code>, so even a valid <code>[1, 2]</code> fails to compile (2020-12's <code>prefixItems</code> isn't read). <code>id</code> and <code>headers</code> take any string, and <code>payment</code> allows a card without a billing address.</p>
<p><code>npx genever order.json --name Order</code> writes:</p>
<pre><code class="language-ts">export type Order = {
    /**
     * @pattern &quot;^ord_&quot;
     */
    id: `ord_${string}`;
    /**
     * @minItems 2
     */
    location?: [number, number];
    shipping?:
        | {
              kind: 'pickup';
              store: string;
          }
        | {
              kind: 'delivery';
              address: string;
          };
    headers?: {
        [key: `x-${string}`]: string;
    };
    payment?: {
        card?: string;
        billingAddress?: string;
    } &amp; (
        | {
              card?: never;
          }
        | {
              billingAddress: string;
          }
    );
};
</code></pre>
<p>Each of those mistakes is now a compile error:</p>
<pre><code class="language-ts">const wrongId: Order = { id: 'usr_1' };
const wrongLocation: Order = { id: 'ord_1', location: [1, 2, 3] };
const wrongHeader: Order = { id: 'ord_1', headers: { accept: 'json' } };
const noBillingAddress: Order = { id: 'ord_1', payment: { card: '4242' } };
</code></pre>
<h2 id="how-the-comparison-works" tabindex="-1">How the comparison works</h2>
<p>One schema proves nothing, so the comparison runs every night in CI. Every tool gets <a href="https://github.com/JacobLey/leyman/blob/main/e2e/genever-comparison/src/documents.ts" target="_blank" rel="noreferrer">the same documents</a>: one JSON Schema with a property per feature (nullable values, mixed enums, tuples, <code>oneOf</code> objects, recursion, closed <code>patternProperties</code>, literal patterns and <code>dependentRequired</code>), and an OpenAPI 3.1 document for the tools that read OpenAPI.</p>
<p>Each <a href="https://github.com/JacobLey/leyman/blob/main/e2e/genever-comparison/src/probes.ts" target="_blank" rel="noreferrer">probe</a> is one line of TypeScript, compiled against the generated type:</p>
<ul>
<li><strong>accepts</strong>: a value the schema allows must type-check.</li>
<li><strong>rejects</strong>: a value the schema forbids must not type-check.</li>
<li><strong>reads</strong>: reading a value must give exactly the right type, compared with an identity check, so <code>any</code> doesn't pass.</li>
</ul>
<p>Most comparisons only check that valid values are accepted, which any wide enough type does; <code>any</code> passes all of them. The rejecting probes are the ones that separate the tools.</p>
<p>I wrote the probes too, so of course genever passes them. Each one follows what the spec says a validator must accept or reject, and the documents and probes are public; if one is unfair to a tool, or a feature is missing, <a href="https://github.com/JacobLey/leyman/issues" target="_blank" rel="noreferrer">open an issue</a>.</p>
<p><a href="https://www.npmjs.com/package/json-schema-to-ts" target="_blank" rel="noreferrer">json-schema-to-ts</a> and <a href="https://www.npmjs.com/package/typebox" target="_blank" rel="noreferrer">TypeBox</a> infer types inside TypeScript instead of writing a file, so they get the schema pasted in <code>as const</code>. That is their best case. Import the same schema from a <code>.json</code> file and TypeScript widens it; json-schema-to-ts then gives <code>unknown</code>.</p>
<h2 id="what-the-latest-run-found" tabindex="-1">What the latest run found</h2>
<p>Results from the 2026-11-06 run, 23 JSON Schema probes each:</p>
<table tabindex="0">
<thead>
<tr>
<th>Tool</th>
<th>Version</th>
<th>Passed</th>
<th>Missed</th>
</tr>
</thead>
<tbody>
<tr>
<td>genever</td>
<td>0.10.1</td>
<td>23</td>
<td></td>
</tr>
<tr>
<td>json-schema-to-typescript</td>
<td>16.0.0</td>
<td>18</td>
<td>tuples, closed <code>patternProperties</code>, literal patterns, <code>dependentRequired</code></td>
</tr>
<tr>
<td>quicktype</td>
<td>26.0.0</td>
<td>17</td>
<td>tuple length, narrowing <code>oneOf</code> objects, closed <code>patternProperties</code>, literal patterns, <code>dependentRequired</code></td>
</tr>
<tr>
<td>json-schema-to-ts</td>
<td>3.1.1</td>
<td>17</td>
<td>tuples, recursion (a type error), closed <code>patternProperties</code>, literal patterns, <code>dependentRequired</code></td>
</tr>
<tr>
<td>TypeBox</td>
<td>1.4.3</td>
<td>17</td>
<td>tuples, recursion, closed <code>patternProperties</code>, literal patterns, <code>dependentRequired</code></td>
</tr>
<tr>
<td>json-schema-to-zod</td>
<td>2.8.1</td>
<td>13</td>
<td>tuples, <code>oneOf</code> objects, recursion (<code>$ref</code> becomes <code>z.any()</code>), closed <code>patternProperties</code>, literal patterns, <code>dependentRequired</code></td>
</tr>
</tbody>
</table>
<p>On the OpenAPI document, genever passed 10 of 10 probes and <a href="https://www.npmjs.com/package/openapi-typescript" target="_blank" rel="noreferrer">openapi-typescript</a> 7.13.0 passed 9, missing only the literal pattern.</p>
<p>The other rows are a snapshot: what each tool did that night, at the version shown. When one of them ships a fix, the next run picks it up. Only genever's results are asserted by the comparison's tests.</p>
<p>Generating from a schema of 500 definitions took genever 54 ms, json-schema-to-typescript 141 ms and quicktype 295 ms (median of 5 runs, on a shared CI runner, so only compare within one run). The tools that infer types aren't timed, since their cost is paid while type-checking.</p>
<p>The live report, from last night's run, with the failed probes on hover:</p>
<p><a href="https://jacobley.github.io/leyman/blog/json-schema-to-typescript-compared">This part is interactive: see it on the original post.</a></p>
<h2 id="where-i-d-use-the-others" tabindex="-1">Where I'd use the others</h2>
<p>If the same schema feeds Go, Swift or Kotlin code, use quicktype: it generates for many languages and can infer a schema from sample JSON, and genever only writes TypeScript. If you want runtime checks and the schema has no <code>$ref</code>s, json-schema-to-zod gives you a Zod validator with the type attached, one generated file instead of a type plus a separate validator. json-schema-to-ts and TypeBox have no build step, which matters when the schema is already written in TypeScript; if it is, <a href="https://jacobley.github.io/leyman/packages/juniper/">Juniper</a> is my version of that, with the same types genever generates. And openapi-typescript has more built around it, openapi-fetch for one; genever's <code>Paths</code> and <code>Operations</code> types are shaped like its output.</p>
<h2 id="run-it-yourself" tabindex="-1">Run it yourself</h2>
<p>The comparison is <a href="https://github.com/JacobLey/leyman/tree/main/e2e/genever-comparison" target="_blank" rel="noreferrer">a package in the repository</a>, and the nightly job runs it with <a href="https://dagger.io/" target="_blank" rel="noreferrer">Dagger</a>, so a clone, Docker and the Dagger CLI are enough to run the same thing:</p>
<pre><code class="language-sh">git clone https://github.com/JacobLey/leyman.git &amp;&amp; cd leyman
dagger call comparison-report --commit=&quot;$(git rev-parse HEAD)&quot; export --path ./genever.json
</code></pre>
<p>Versions are pinned in the lockfile, so running the same commit gives the same versions as the table.</p>
<h2 id="try-it" tabindex="-1">Try it</h2>
<pre><code class="language-sh">npx genever schema.json --name MyType
</code></pre>
<p>Commit the output, and run <code>genever --ci</code> in CI to fail when it's out of date. <a href="https://jacobley.github.io/leyman/packages/genever/getting-started">Getting started</a> covers the rest, including <a href="https://jacobley.github.io/leyman/packages/genever/guides/openapi">OpenAPI documents</a> and <a href="https://jacobley.github.io/leyman/packages/genever/guides/references">references to other files</a>.</p>
<p>A type doesn't check anything at runtime. <code>minLength</code>, <code>format</code> and most patterns have no TypeScript equivalent, so data still needs a validator: <a href="https://ajv.js.org/" target="_blank" rel="noreferrer">Ajv</a> or <a href="https://jacobley.github.io/leyman/packages/juniper-validator/">juniper-validator</a>.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
