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';
}
*/
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" };
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
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(...)
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 }
*/
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);
}
};
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
}
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
- Schema builder:
@jetio/schema-builder- GitHub - Validator:
@jetio/validator- GitHub - The inference engine lives in
src/types/jetter.ts
If you run into edge cases, open an issue there's always room to improve.
Top comments (0)