A TypeScript type can tell you exactly what an object is supposed to look like.
Imagine an API expects this:
interface User {
id: number;
name: string;
email?: string;
}
That definition is useful throughout a TypeScript application. The compiler knows that id should be a number, name should be a string, and email does not have to be present.
But now imagine the object arrives in an HTTP request:
{
"id": "not-a-number",
"name": 42
}
The TypeScript interface does not stop that JSON from arriving. It cannot, because the interface describes what the program expects while the request contains what another system actually sent.
That is the problem JSON Schema solves. TypeScript gives you static confidence about your code; JSON Schema can give you runtime evidence about your data.
JSON Schema Makes the Expected Shape Checkable
JSON Schema is a machine-readable way to describe the structure and constraints of JSON data.
The User above could have a schema resembling:
{
"type": "object",
"properties": {
"id": { "type": "number" },
"name": { "type": "string" },
"email": { "type": "string" }
},
"required": ["id", "name"],
"additionalProperties": false
}
The resemblance to the TypeScript interface is obvious. Both describe an object with an id, name, and optional email.
The difference is what can be done with the description.
TypeScript can reject incorrect code while compiling:
const user: User = {
id: "abc",
name: "Ada"
};
A JSON Schema validator can reject incorrect data while the application is running.
That difference becomes important whenever data crosses an API boundary.
External JSON
↓
JSON Schema validation
↓
Known valid shape
↓
TypeScript application
The schema does not replace the TypeScript type. It establishes whether an unknown runtime value is safe to treat as that type.
TypeScript Checks the Program, Not the Network
It is easy to lose this distinction because TypeScript makes values feel more trustworthy than they really are.
Suppose you write:
const body = (await request.json()) as User;
The as User assertion may satisfy TypeScript, but nothing has been validated. If the client sent:
{
"id": null,
"name": ["Ada"]
}
those values do not magically become a number and a string.
The assertion changes what TypeScript believes about body; it does not change body.
This is why compile-time type checking and runtime validation belong together rather than competing with each other. Once runtime validation has established that incoming data satisfies the contract, TypeScript can preserve that knowledge through the rest of the application.
The interesting problem is that you now appear to have the same contract in two places.
One Data Contract Can Easily Become Two Definitions
If an API uses JSON Schema for validation and TypeScript types for application code, a team could maintain both manually:
user.schema.json
+
user.types.ts
At first, that seems harmless.
Then the API changes.
Perhaps email becomes required, a status field is introduced, or an existing property changes shape. Someone updates the TypeScript interface but forgets the JSON Schema.
Now the compiler and validator disagree about what a valid user is.
That is schema drift, and it points to a larger design question: if both representations describe the same data contract, which one is the source of truth?
There are several ways to answer that question.
Schema-First Development Starts With the Runtime Contract
In a schema-first design, the schema owns the contract.
The direction is:
JSON Schema
↓
TypeScript types
Developers define what valid external data looks like and generate the corresponding TypeScript types from it.
This makes sense when the contract exists beyond one TypeScript codebase. An API schema might need to be understood by multiple services, client generators, documentation tools, validators, or programs written in completely different languages.
The JSON Schema remains independent of TypeScript, while the TypeScript representation becomes a derived artifact.
If the schema changes, the types are regenerated.
That removes the need to manually make the same change twice.
Type-First Development Reverses the Direction
A TypeScript heavy project may prefer to begin with the types developers already work with:
TypeScript types
↓
JSON Schema
Now the TypeScript model is authoritative and tooling generates a runtime schema from it.
This can produce a comfortable development experience because developers do not have to describe every structure twice. They work in TypeScript, and the runtime representation follows.
There is a complication, though: TypeScript and JSON Schema are not equivalent type systems.
TypeScript can describe concepts designed for JavaScript programs, while JSON Schema describes JSON data. Sophisticated TypeScript features do not always have a clean runtime JSON equivalent, and some validation rules contain information that a normal TypeScript type never expressed.
Consider:
interface Order {
quantity: number;
}
The real API rule might be that quantity must be a positive integer.
A JSON Schema can express that constraint. The TypeScript declaration above cannot tell a generator that 0, -4, or 2.5 should be rejected.
Generation can remove duplication, but it cannot recover rules that were never represented in its source.
A Third Approach Lets the Runtime Schema Produce the Type
Some TypeScript validation libraries take a slightly different route.
Instead of writing a TypeScript interface and then generating a validator, the developer defines a runtime schema in TypeScript:
const UserSchema = object({
id: number(),
name: string(),
email: optional(string())
});
The library can then infer a static TypeScript type from that schema.
Conceptually, the relationship becomes:
Runtime schema
├──→ validator
│
└──→ TypeScript type
There is still one source of truth, but developers get both runtime behavior and compile-time information from it.
This approach avoids maintaining two independent definitions. Whether it is preferable to standard JSON Schema as the primary contract depends on where that contract needs to travel and what other systems need to consume it, especially when contracts are verified outside one codebase.
The important architectural property is not the direction of generation by itself. It is that one definition owns the contract and the others are derived from it.
The Mapping Looks Simple Until the Data Gets Interesting
For ordinary JSON structures, the relationship between schema and TypeScript is fairly intuitive.
JSON gives us objects, arrays, strings, numbers, booleans, and null. From those building blocks, schemas can describe increasingly complicated data.
A nested TypeScript structure might look like:
interface User {
id: number;
profile: {
displayName: string;
verified: boolean;
};
roles: ("admin" | "editor")[];
}
A corresponding schema can describe the nested profile object, require its properties, restrict roles to particular values, and require every item in the array to satisfy those rules.
Required and optional properties are particularly important at an API boundary. If displayName is required, a validator can reject a request where it is missing rather than allowing incomplete data farther into the application.
Schemas can also decide what happens to properties that were never declared.
Suppose a client sends:
{
"id": 42,
"profile": {
"displayName": "Ada",
"verified": true
},
"isSuperAdmin": true
}
If the contract uses additionalProperties: false at the relevant object level, the unexpected field can be rejected.
These details show why runtime validation is more than attaching TypeScript-shaped labels to JSON. The schema defines what the boundary will actually accept.
A Validator Turns the Schema Into Runtime Proof
JSON Schema by itself describes the rules. A schema validator applies those rules to an actual value.
That changes the status of incoming data.
Before validation:
"We received some JSON."
After successful validation:
"We received JSON that satisfies this contract."
The distinction matters because TypeScript’s guarantees depend on its assumptions being true.
If unvalidated external data is simply asserted to have a type, the compiler is reasoning from an assumption the application never checked. Runtime validation gives that assumption evidence.
This is the heart of type safety versus data validation.
Type safety helps prevent your own typed code from using values inconsistently. Data validation protects the program from values that did not originate under those guarantees.
Schema Drift Is Really a Source-of-Truth Problem
Once this distinction is clear, schema drift becomes easier to understand.
Suppose a schema allows:
pending | approved | rejected
but someone updates the TypeScript type to:
type Status =
| "pending"
| "approved"
| "rejected"
| "cancelled";
The application now contains a state that its runtime contract may reject.
The reverse can happen too. The schema may evolve while generated or handwritten TypeScript types remain stale, leaving application code unaware of data the validator accepts, a quieter version of the dependency mismatch described in SLA design.
Code generation reduces this risk only if generated files are actually kept current. A generated type that has not been regenerated for six months is still a stale type.
That is why generation normally belongs in a repeatable build or development process rather than being treated as a one-time conversion.
The goal is simple:
change contract
↓
derive representations
↓
compile and validate
The farther developers can get from manually synchronizing equivalent definitions, the harder accidental drift becomes.
API Boundaries Show Why Both Sides Matter
The value of this arrangement becomes clearest in an API.
A request arrives:
{
"productId": 42,
"quantity": 3
}
The server should not trust it simply because a TypeScript interface somewhere says:
interface CreateOrderRequest {
productId: number;
quantity: number;
}
The request came from outside the program.
Instead, the boundary can validate the actual payload against its schema. Invalid requests are rejected before they reach business logic, while valid requests enter the application with a shape TypeScript can safely work with.
The same principle can apply in the opposite direction.
If an API promises a particular response shape, validating or otherwise enforcing that contract helps prevent an internal change from silently returning something clients were never told to expect.
The full journey looks like this:
incoming JSON
↓
runtime validation
↓
typed application logic
↓
contract-compliant response
This is where JSON Schema and TypeScript stop looking like competing ways to describe the same object.
They are protecting different stages of the same journey.
TypeScript describes the static intent of the program: what values should look like and how code is allowed to use them. JSON Schema provides a way to test runtime reality against an explicit data contract.
The architectural problem is keeping those two views from drifting apart. Schema-first generation, TypeScript-first generation, and dynamically inferred types are different ways of solving that problem, but they share the same objective: define the contract once, derive what can be derived, and validate data at the boundaries where static assumptions alone are not proof.





