DEV Community

Jacob Ley
Jacob Ley

Posted on Originally published at jacobley.github.io on

How narrow are generated TypeScript types? Six JSON Schema generators compared

A JSON Schema can say more than most generated TypeScript types do. A tuple of two numbers becomes number[], an ID that must start with ord_ becomes string, and an object that only allows x- headers takes any key. The type compiles, and bad data goes straight through it.

My existing package, Juniper, 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 safely pulled into TypeScript that way.

I wrote genever 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.

The same schema, two outputs

A schema for an order, with one property per feature that TypeScript can express but most generators don't:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "id": { "type": "string", "pattern": "^ord_" },
    "location": {
      "type": "array",
      "prefixItems": [{ "type": "number" }, { "type": "number" }],
      "items": false,
      "minItems": 2
    },
    "shipping": {
      "oneOf": [
        {
          "type": "object",
          "properties": { "kind": { "const": "pickup" }, "store": { "type": "string" } },
          "required": ["kind", "store"],
          "additionalProperties": false
        },
        {
          "type": "object",
          "properties": { "kind": { "const": "delivery" }, "address": { "type": "string" } },
          "required": ["kind", "address"],
          "additionalProperties": false
        }
      ]
    },
    "headers": {
      "type": "object",
      "patternProperties": { "^x-": { "type": "string" } },
      "additionalProperties": false
    },
    "payment": {
      "type": "object",
      "properties": { "card": { "type": "string" }, "billingAddress": { "type": "string" } },
      "dependentRequired": { "card": ["billingAddress"] },
      "additionalProperties": false
    }
  },
  "required": ["id"],
  "additionalProperties": false
}

Enter fullscreen mode Exit fullscreen mode

json-schema-to-typescript 16.0.0, the most-downloaded tool that writes a .ts file from JSON Schema, generates this (banner comment removed):

export interface Order {
  id: string;
  /**
   * @minItems 2
   */
  location?: never[];
  shipping?:
    | {
        kind: "pickup";
        store: string;
      }
    | {
        kind: "delivery";
        address: string;
      };
  headers?: {
    /**
     * This interface was referenced by `undefined`'s JSON-Schema definition
     * via the `patternProperty` "^x-".
     */
    [k: string]: string;
  };
  payment?: {
    card?: string;
    billingAddress?: string;
  };
}

Enter fullscreen mode Exit fullscreen mode

The oneOf union is right. location is never[], so even a valid [1, 2] fails to compile (2020-12's prefixItems isn't read). id and headers take any string, and payment allows a card without a billing address.

npx genever order.json --name Order writes:

export type Order = {
    /**
     * @pattern "^ord_"
     */
    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;
    } & (
        | {
              card?: never;
          }
        | {
              billingAddress: string;
          }
    );
};

Enter fullscreen mode Exit fullscreen mode

Each of those mistakes is now a compile error:

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' } };

Enter fullscreen mode Exit fullscreen mode

How the comparison works

One schema proves nothing, so the comparison runs every night in CI. Every tool gets the same documents: one JSON Schema with a property per feature (nullable values, mixed enums, tuples, oneOf objects, recursion, closed patternProperties, literal patterns and dependentRequired), and an OpenAPI 3.1 document for the tools that read OpenAPI.

Each probe is one line of TypeScript, compiled against the generated type:

  • accepts : a value the schema allows must type-check.
  • rejects : a value the schema forbids must not type-check.
  • reads : reading a value must give exactly the right type, compared with an identity check, so any doesn't pass.

Most comparisons only check that valid values are accepted, which any wide enough type does; any passes all of them. The rejecting probes are the ones that separate the tools.

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, open an issue.

json-schema-to-ts and TypeBox infer types inside TypeScript instead of writing a file, so they get the schema pasted in as const. That is their best case. Import the same schema from a .json file and TypeScript widens it; json-schema-to-ts then gives unknown.

What the latest run found

Results from the 2026-11-06 run, 23 JSON Schema probes each:

Tool Version Passed Missed
genever 0.10.1 23
json-schema-to-typescript 16.0.0 18 tuples, closed patternProperties, literal patterns, dependentRequired
quicktype 26.0.0 17 tuple length, narrowing oneOf objects, closed patternProperties, literal patterns, dependentRequired
json-schema-to-ts 3.1.1 17 tuples, recursion (a type error), closed patternProperties, literal patterns, dependentRequired
TypeBox 1.4.3 17 tuples, recursion, closed patternProperties, literal patterns, dependentRequired
json-schema-to-zod 2.8.1 13 tuples, oneOf objects, recursion ($ref becomes z.any()), closed patternProperties, literal patterns, dependentRequired

On the OpenAPI document, genever passed 10 of 10 probes and openapi-typescript 7.13.0 passed 9, missing only the literal pattern.

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.

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.

The live report, from last night's run, with the failed probes on hover:

This part is interactive: see it on the original post.

Where I'd use the others

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 $refs, 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, Juniper 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 Paths and Operations types are shaped like its output.

Run it yourself

The comparison is a package in the repository, and the nightly job runs it with Dagger, so a clone, Docker and the Dagger CLI are enough to run the same thing:

git clone https://github.com/JacobLey/leyman.git && cd leyman
dagger call comparison-report --commit="$(git rev-parse HEAD)" export --path ./genever.json

Enter fullscreen mode Exit fullscreen mode

Versions are pinned in the lockfile, so running the same commit gives the same versions as the table.

Try it

npx genever schema.json --name MyType

Enter fullscreen mode Exit fullscreen mode

Commit the output, and run genever --ci in CI to fail when it's out of date. Getting started covers the rest, including OpenAPI documents and references to other files.

A type doesn't check anything at runtime. minLength, format and most patterns have no TypeScript equivalent, so data still needs a validator: Ajv or juniper-validator.

Top comments (0)