DEV Community

Cover image for I pushed Typescript to its limit all because of JSON schema
Great Venerable
Great Venerable

Posted on

I pushed Typescript to its limit all because of JSON schema

Someone opened a GitHub issue on my JSON Schema validator asking for TypeScript type inference. I figured: how hard could it be?
Ten days later, I had pushed TypeScript's type system further than I knew was possible, hit recursion limits I didn't know existed, and built something I genuinely believe no other library does.

Some context

I've been building @jetio/validator, a JSON Schema compliant validator that compiles schemas to functions. After publishing @jetio/schema-builder on top of it in January, the feature request came in: Jet.Infer<typeof schema>. Give it a schema, get back a TypeScript type that is a 1:1 reflection of what the validator accepts at runtime.
The key word is compliance. This isn't a custom DSL like Zod where you control the semantics. JSON Schema has a spec, and types have to mirror it exactly.If the validator rejects it at runtime, TypeScript has to reject it at compile time too.

Here's what the goal looked like:

const userSchema = new SchemaBuilder()
  .object()
  .properties({
    id: (s) => s.number(),
    name: (s) => s.string(),
    role: (s) => s.enum(['admin', 'user', 'guest'])
  })
  .required(['id', 'name', 'role'])
  .build();

type User = Jet.Infer<typeof userSchema>;
/*
{
  id: number;
  name: string;
  role: 'admin' | 'user' | 'guest';
}
*/
Enter fullscreen mode Exit fullscreen mode

Simple enough on the surface. Then I got to the hard parts.

Problem 1: oneOf exclusivity without a discriminator

Most libraries infer oneOf as a plain union A | B. That's wrong. JSON Schema's oneOf means exactly one and only one branch can match, anything else is a type error.

The problem is TypeScript doesn't enforce this on plain unions:

// What other libraries give you
type Payment = 
  | { card: string }
  | { paypal: string };

// TypeScript is totally fine with this. oneOf is not.
const p: Payment = { card: "1234", paypal: "test@me.com" };
Enter fullscreen mode Exit fullscreen mode

My solution: iterate over every branch, collect all unique keys across the entire union, then mark every key as never in the branches where it doesn't belong.

const schema = new SchemaBuilder()
  .oneOf(
    (s) => s.object().properties({ card: (s) => s.string() }).required(['card']),
    (s) => s.object().properties({ paypal: (s) => s.string() }).required(['paypal'])
  )
  .build();

type Payment = Jet.Infer<typeof schema>;
/*
  | { card: string; paypal?: never }
  | { paypal: string; card?: never }
*/

const p: Payment = { card: "1234", paypal: "test@me.com" };
// ❌ TypeScript Error just as the spec intends
Enter fullscreen mode Exit fullscreen mode

No discriminator field needed. True exclusive unions, enforced at the type level.

Problem 2: elseIf chains

JSON Schema supports if/then/else for conditional validation. I added a custom elseIf keyword to avoid the deeply nested chains that standard JSON Schema forces on you:

// Without elseIf you get deeply nested hell
.if(...).then(...).else(s => s.if(...).then(...).else(s => s.if(...)))

// With elseIf you get a clean chain
.if(...).then(...).elseIf(...).then(...).elseIf(...).then(...).else(...)
Enter fullscreen mode Exit fullscreen mode

Getting the validator to support it was straightforward. Getting the type inference right was something else entirely.

The problem: a single .then() call needs to correctly update the types for its own if branch and for every preceding elseIf branch, because elseIf is an array internally.

My solution was iterating over the elseIf array at the type level and patching whichever entry was missing its then. The result is a fully typed conditional chain that mirrors runtime behavior:

const shippingSchema = new SchemaBuilder()
  .object()
  .properties({ country: (s) => s.string(), weight: (s) => s.number() })
  .required(['country', 'weight'])
  .if((s) => s.object().properties({ country: (s) => s.const('US') }))
  .then((s) => s.object().properties({ method: (s) => s.enum(['USPS', 'UPS', 'FedEx']) }).required(['method']))
  .elseIf((s) => s.object().properties({ country: (s) => s.const('CA') }))
  .then((s) => s.object().properties({ method: (s) => s.enum(['Canada Post', 'Purolator']) }).required(['method']))
  .else((s) => s.object().properties({ method: (s) => s.string() }).required(['method']))
  .build();

type Shipping = Jet.Infer<typeof shippingSchema>;
/*
  | { country: 'US'; weight: number; method: 'USPS' | 'UPS' | 'FedEx' }
  | { country: 'CA'; weight: number; method: 'Canada Post' | 'Purolator' }
  | { country: string; weight: number; method: string }
*/
Enter fullscreen mode Exit fullscreen mode

Each branch is exclusive. TypeScript narrows correctly on country. No other library I know of does this for elseIf or even have the elseIf keyword.

Problem 3: allOf that stays readable

allOf is conceptually simple you just merge all schemas together. What makes it hard is when oneOf, anyOf, or conditionals are present in the same schema, because the inference engine evaluates all keywords simultaneously, which means allOf has to survive that process intact.
The solution was to resolve each keyword independently first then get the individual union results from oneOf, anyOf, and conditionals separately then merge the allOf result into each individual branch of those unions. Every branch ends up carrying whatever allOf contributes, achieving true spec-compliant inference.

The recursion limit problem

The one I didn't see coming. TypeScript has a recursion depth limit, and type-level JSON Schema processing hits it constantly, especially with deep allOf nesting or complex conditional chains.
After going back and forth on it for a while, I switched the internal resolution from a recursive approach to an iterative one. That fixed it, but it meant rewriting a significant chunk of the engine.

There were also issues with VSCode truncating inferred types in tooltips before I could see the full output. I had to write custom Exclude utility chains just to inspect what the system was actually producing. Not fun.

What it all adds up to

The final result is the Jetter system, Jet.Infer<>.Type inference that handles:

oneOf with automatic exclusive union enforcement (no discriminator needed)
anyOf as standard unions, with discrimination when a shared field exists
allOf collapsed into a single readable object
if/then/elseIf/else chains as typed discriminated unions
patternProperties mapped to TypeScript template literals (^user_ → user_${string})
additionalProperties and unevaluatedProperties
prefixItems, items, additionalItems, including tuples with rest elements

And because it's built on an actual JSON Schema validator, the types mirror runtime behavior exactly. No drift between what TypeScript accepts and what the validator accepts.

const validator = new JetValidator();
const validate = validator.compile(schema);

type User = Jet.Infer<typeof schema>;

const processUser = (data: unknown) => {
  if (validate(data)) {
    const user = data as User;
    // TypeScript knows the exact shape here
    // Runtime has already verified it matches
    console.log(user.name);
  }
};
Enter fullscreen mode Exit fullscreen mode

One TypeScript limitation worth knowing

There's one thing I couldn't solve cleanly, and it's a TypeScript limitation rather than a design choice:

type Config = {
  appName: string;
  [x: string]: boolean; // This overrides appName — TypeScript doesn't allow the conflict
}
Enter fullscreen mode Exit fullscreen mode

Because of this, additionalProperties with a constraint only fully applies when no properties are defined at that level. When both exist, the index signature falls back to [x: string]: anyrather than enforcing the constraint type alongside the named properties. The runtime validator still enforces the constraint correctly so it's purely a type-level limitation.

I never formally learned TypeScript

I started using it with NestJS and figured things out as I went. I genuinely thought TypeScript was just interfaces and as unknown or as any. Building this showed me it's a full type-level programming language, one where you can iterate over arrays, patch conditional branches, collapse intersections, and enforce structural exclusivity.
The ten days I spent building this were not healthy in terms of hours. But the end result is something I'm genuinely proud of: spec-compliant type inference that no other JSON Schema library offers at this level.

There is certainly room for improvement as there are scenarios I haven't covered yet, and I believe this could become something even more remarkable with the help of the TypeScript community.

Links

If you run into edge cases, open an issue there's always room to improve.

Top comments (0)