TypeScript types and JSON Schema often look like two versions of the same idea. Both can describe an object with an id, a name, and an email. Both can say that one field should be a number and another should be a string. Both can be used around APIs, forms, configuration files, and shared data models.
The similarity is real, but it hides the important difference. TypeScript types are mainly for developers and tooling before the program runs. JSON Schema is mainly for validating real JSON data while the program is running. One improves how confidently you write code. The other checks whether outside data deserves to enter the system.
That distinction matters because most production bugs do not ask permission from the compiler. A webhook payload can be malformed. A third-party API can change a field. A configuration file can be edited by hand. A browser can send an unexpected request body. TypeScript can make your codebase safer, but it cannot validate data after its types have been erased from the emitted JavaScript.
The Difference in One Example
Suppose an application expects this shape:
type Customer = {
id: number;
email: string;
plan: "free" | "pro" | "enterprise";
};
That type helps developers use Customer correctly inside the TypeScript codebase. The editor can autocomplete customer.email, the compiler can reject customer.plan = "premium" if "premium" is not allowed, and refactors can be checked across many files. This is valuable, but it happens during development and compilation.
Now imagine the same object arriving from an HTTP request:
{
"id": "not-a-number",
"email": false,
"plan": "gold"
}
If the server blindly writes this line, the compiler has no way to inspect the runtime payload:
const customer = JSON.parse(body) as Customer;
The as Customer assertion does not validate anything. It tells TypeScript to trust the developer. At runtime, the object still contains a string where a number was expected, a boolean where an email string was expected, and a plan value the application does not support.
JSON Schema solves a different problem. It can define rules that are checked against the actual payload:
{
"type": "object",
"required": ["id", "email", "plan"],
"properties": {
"id": { "type": "integer" },
"email": { "type": "string", "format": "email" },
"plan": { "enum": ["free", "pro", "enterprise"] }
},
"additionalProperties": false
}
A validator can reject the bad payload before application logic treats it as a real customer. That is runtime proof, not just static intent.
What TypeScript Types Are Good At
TypeScript types are excellent at describing how code is supposed to be used. They help developers see available properties, pass the right arguments to functions, return the expected shapes, and catch many mistakes before code reaches production. They also make large refactors less terrifying because the compiler can point to affected call sites.
Types are especially strong inside the trust boundary of an application. If a function creates a Customer object from already validated data, TypeScript can help keep that object consistent as it moves through services, React components, reducers, API clients, and database mapping code.
For example:
type Invoice = {
invoiceId: string;
totalCents: number;
currency: "USD" | "EUR" | "GBP";
paid: boolean;
};
function formatInvoiceTotal(invoice: Invoice) {
return new Intl.NumberFormat("en", {
style: "currency",
currency: invoice.currency
}).format(invoice.totalCents / 100);
}
The compiler can help ensure that currency is one of the allowed values and that totalCents is treated as a number. The editor can show the shape to anyone using the function. That feedback loop is fast and ergonomic.
The limitation is that TypeScript does not survive as a runtime enforcement layer. Once the code is compiled to JavaScript, those type declarations are gone. Unless you add validation, external data can still be wrong.
What JSON Schema Is Good At
JSON Schema is a language-independent way to describe and validate JSON data. It can specify required properties, allowed types, string formats, numeric ranges, array lengths, object structures, enum values, conditional rules, and whether unknown fields are allowed. A validator such as Ajv can compile the schema and check real payloads at runtime.
That makes JSON Schema useful at boundaries:
- HTTP request bodies
- Webhook payloads
- Configuration files
- Message queue events
- Data imports
- Public API responses
- Partner integrations
- Generated forms
- OpenAPI documents
These are places where trust is incomplete. The data may come from a user, a partner, a browser, another service, a CLI flag, a CMS, or a file edited outside the codebase. TypeScript can describe what the application wants. JSON Schema can check what the application actually received.
For a payment settings file, a schema might enforce values like this:
{
"type": "object",
"required": ["provider", "captureMode", "maxRetries"],
"properties": {
"provider": { "enum": ["stripe", "adyen", "manual"] },
"captureMode": { "enum": ["automatic", "manual"] },
"maxRetries": { "type": "integer", "minimum": 0, "maximum": 5 }
}
}
Without runtime validation, a typo such as "stripee" or a string such as "three" may sit unnoticed until a payment path fails. With schema validation, the application can reject the configuration at startup with a precise error.
Compile Time and Runtime
The simplest mental model is time. TypeScript works before and during compilation. JSON Schema works while the program is running.
developer writes code
TypeScript checks code
JavaScript is emitted
program receives real data
JSON Schema validates data
application logic runs
This timeline explains why the tools are complementary. TypeScript cannot reject a malformed webhook that arrives tomorrow unless some runtime code checks the payload. JSON Schema cannot give the same rich editor feedback across a TypeScript codebase unless you connect it to generated or inferred types.
If a value is created inside typed code from trusted inputs, TypeScript may be enough. If a value crosses a boundary from outside the application, runtime validation becomes much more important.
The Boundary Problem
Most applications have trust boundaries. A boundary is any place where data enters from a source the compiler did not control. Request bodies, environment variables, third-party responses, local storage, message brokers, files, and form submissions are all boundaries.
TypeScript can make boundary code look deceptively safe:
app.post("/webhooks/payment", async (req, res) => {
const event = req.body as PaymentEvent;
await handlePaymentEvent(event);
res.sendStatus(204);
});
That code says req.body is a PaymentEvent, but it does not prove it. If the webhook provider changes a field, retries an old event shape, sends a test payload, or if a malicious caller posts nonsense to the endpoint, the assertion does not protect the handler.
A safer boundary validates first:
app.post("/webhooks/payment", async (req, res) => {
const result = validatePaymentEvent(req.body);
if (!result.valid) {
res.status(400).json({ error: "Invalid payment event" });
return;
}
await handlePaymentEvent(result.data);
res.sendStatus(204);
});
After validation, the application can treat the data as trusted. This is where the type and schema should meet: the validator proves the runtime shape, and TypeScript helps the rest of the code use the validated value correctly.
Where JSON Schema Is More Expressive Than a Simple Type
Many TypeScript types describe broad shapes. JSON Schema can often describe input constraints that are more operational. It can say that a string must be an email, that an array must contain at least one item, that a number must be between two limits, or that no additional properties are allowed.
For example, this TypeScript type is useful but incomplete:
type CreateCouponRequest = {
code: string;
percentOff: number;
};
The application probably needs more rules:
{
"type": "object",
"required": ["code", "percentOff"],
"properties": {
"code": {
"type": "string",
"minLength": 3,
"maxLength": 32,
"pattern": "^[A-Z0-9_-]+$"
},
"percentOff": {
"type": "number",
"minimum": 1,
"maximum": 90
}
},
"additionalProperties": false
}
TypeScript can represent some of this with branded types or careful helper functions, but it does not automatically enforce those constraints on untrusted JSON. A string type does not mean “uppercase coupon code with a maximum length of 32.” It just means string.
Where TypeScript Is More Ergonomic
JSON Schema is not a replacement for TypeScript inside application code. Writing every internal function against raw schema objects would be painful. TypeScript gives a better developer experience for day-to-day programming: autocomplete, narrowing, generics, discriminated unions, mapped types, utility types, and readable function signatures.
Consider a discriminated union:
type Notification =
| { kind: "email"; address: string; subject: string }
| { kind: "sms"; phoneNumber: string; message: string }
| { kind: "push"; deviceId: string; title: string };
TypeScript can narrow the object based on kind:
function sendNotification(notification: Notification) {
if (notification.kind === "email") {
return sendEmail(notification.address, notification.subject);
}
if (notification.kind === "sms") {
return sendSms(notification.phoneNumber, notification.message);
}
return sendPush(notification.deviceId, notification.title);
}
That sort of flow-sensitive assistance is one of TypeScript’s strengths. JSON Schema can validate a similar union at the boundary, but it is not the tool you want driving every editor interaction in application logic.
The Duplication Problem
The obvious risk in using both tools is maintaining the same model twice. If one developer updates the TypeScript type and forgets the JSON Schema, the system now has two conflicting definitions. The code may compile while runtime validation rejects valid requests, or validation may accept data the application no longer understands.
Duplication is not just annoying. It weakens trust. Once people notice that the schema and the type can drift, they stop believing either one fully.
The solution is to pick a source of truth or use tooling that keeps the two representations synchronized. There are several common approaches, each with tradeoffs.
Schema-First Development
In a schema-first workflow, JSON Schema, OpenAPI, AsyncAPI, or another contract format is the source of truth. TypeScript types are generated from that schema. This works well when the API contract is shared across languages, published to partners, or governed independently from one TypeScript codebase.
The flow looks like this:
schema or API specification
generated TypeScript types
runtime validation
application code
The advantage is interoperability. A JSON Schema can be used by TypeScript services, Java services, Python tools, documentation generators, form builders, and API gateways. The drawback is that generated types may not always feel as natural as hand-written TypeScript, especially for complex schemas.
Schema-first is often a good fit for public APIs, platform teams, partner integrations, configuration formats, and event contracts.
Type-First Development
In a type-first workflow, developers write a TypeScript-friendly schema definition and derive runtime validation from it, or they generate JSON Schema from TypeScript. Libraries such as Zod and TypeBox are popular because they let TypeScript developers define validation rules close to application code while still getting inferred types.
With Zod, the model may look like this:
const CreateUser = z.object({
email: z.string().email(),
displayName: z.string().min(1),
role: z.enum(["member", "admin"])
});
type CreateUser = z.infer<typeof CreateUser>;
The developer writes one definition. The application gets runtime validation and a TypeScript type. This is ergonomic for TypeScript-heavy teams and internal services.
The tradeoff is ecosystem reach. A Zod schema is not the same thing as a plain JSON Schema document unless you generate one, and some advanced TypeScript patterns do not translate cleanly into language-neutral contracts.
OpenAPI and API Contracts
Many web APIs use OpenAPI as the contract layer. OpenAPI uses a schema system based on JSON Schema concepts to describe request bodies, responses, parameters, status codes, authentication, and endpoints. From that specification, teams can generate TypeScript clients, server types, documentation, mocks, and validators.
This is where JSON Schema and TypeScript become part of a larger API workflow. The schema validates data. The TypeScript types improve the client and server code. Contract tests can verify that the implementation still matches the published agreement. For the testing side of that boundary, see Contract Tests and Integration Tests.
The practical rule is to avoid treating documentation, types, validators, and tests as separate hand-maintained realities. The more representations you have, the more important generation and verification become.
Example: Configuration Files
Configuration is a good example because it often lives outside TypeScript. A deployment system may load JSON from a file or environment-specific store:
{
"region": "us-east-1",
"logLevel": "info",
"retry": {
"attempts": 3,
"timeoutMs": 1500
}
}
A TypeScript type helps after the configuration is loaded:
type AppConfig = {
region: string;
logLevel: "debug" | "info" | "warn" | "error";
retry: {
attempts: number;
timeoutMs: number;
};
};
But the application still needs to prove that the file contains those values before trusting it. A schema can reject "verbose" as a log level or "three" as a retry count at startup. That gives the team a clear deployment failure instead of a runtime surprise hours later.
Example: Third-Party API Responses
Developers often trust third-party SDKs and generated clients too much. Even when a provider publishes types, the wire response can still be unavailable, partial, versioned, delayed, or different from what your code expects. Runtime validation at important boundaries can turn a confusing downstream error into a precise integration failure.
For example, a marketing platform may return campaign statistics. Your TypeScript client says spendCents is a number, but a temporary API issue returns null. Without validation, that null may flow into reporting math and produce NaN, blank charts, or corrupted aggregates. With boundary validation, the collector can quarantine the bad response and record a structured error.
This is not about distrusting every service all the time. It is about being explicit where external data enters systems that make decisions.
Example: Webhook Events
Webhooks are another place where runtime validation earns its keep. Providers can send old event versions, test events, retries, new optional fields, or malformed payloads. Attackers can send arbitrary requests to public endpoints. A TypeScript type in your repository does not constrain any of that traffic.
A robust webhook handler verifies the signature, validates the payload, records unknown or unsupported event types, and only then calls business logic. The schema should include required identifiers, event names, timestamps, and nested object rules. After validation, TypeScript types keep the handler code clear.
This combination produces better failure modes. Invalid webhook payloads become controlled 400 responses or quarantined events, not undefined property errors in payment or fulfillment code.
Do You Need Both?
Small internal tools may not need a formal JSON Schema for every object. If data is created and consumed inside one TypeScript codebase, and the cost of bad data is low, TypeScript may provide enough safety. Adding schemas everywhere can become ceremony.
Public APIs, partner integrations, webhooks, configuration files, data imports, and microservices usually benefit from runtime validation. The more independent the producer and consumer are, the more valuable an executable schema becomes. If the data crosses a process, team, language, deployment, or trust boundary, TypeScript alone is usually not enough.
A useful rule of thumb is:
Inside trusted TypeScript code: types do most of the work.
At external boundaries: validate at runtime, then use types.
Across teams or languages: publish a contract and generate what you can.
That keeps validation focused where it reduces real risk.
Common Mistakes
The first mistake is using as SomeType as validation. A type assertion changes TypeScript’s opinion; it does not change or check the value. If the data is untrusted, assertions can hide the exact problem they appear to solve.
The second mistake is validating too late. If invalid data enters the core of the application, every downstream function has to defend itself. Boundary validation keeps the rest of the system simpler.
The third mistake is making schemas too loose. If every field is optional and unknown properties are allowed everywhere, the schema may accept payloads the application cannot actually handle.
The fourth mistake is making schemas too strict in the wrong places. Public APIs often need backward-compatible evolution. Rejecting harmless unknown fields from a provider response may create unnecessary fragility. Strictness should match ownership and compatibility needs.
The fifth mistake is maintaining schemas and types manually without checks. If both exist, add generation, tests, or review rules that catch drift.
Tooling Options
Ajv is a widely used JSON Schema validator in JavaScript and TypeScript systems. It is fast, mature, and fits teams that already use JSON Schema or OpenAPI. Zod is popular for TypeScript-first application code because it combines runtime validation with inferred static types. TypeBox provides TypeScript-friendly schema definitions that produce JSON Schema. OpenAPI tooling can generate clients, server types, documentation, and validators from API specifications.
The best tool depends on where the source of truth should live. If your contract must be language-neutral, start with JSON Schema or OpenAPI. If your application is TypeScript-first and mostly internal, a library that defines validators and infers types may be more comfortable. If multiple services depend on the same boundary, make generation and contract testing part of CI.
A Practical Architecture
A clean TypeScript API service often uses layered confidence:
request arrives
signature or authentication is checked
JSON Schema or runtime validator checks payload
validated data is converted into typed application objects
business logic runs with TypeScript support
response is serialized and optionally validated
This pattern keeps untrusted data near the edge. The core application does not need to repeatedly ask whether email is actually a string, because the boundary already proved it. TypeScript then makes the internal code pleasant and safer to maintain.
For outbound API calls, the same pattern works in reverse. Validate or decode important external responses before storing them or using them for decisions. This is especially useful for data pipelines where one malformed provider response can pollute historical records.
Conclusion
TypeScript types and JSON Schema are not rivals. They operate at different moments and protect against different mistakes. TypeScript expresses developer intent and improves code before it runs. JSON Schema validates real JSON data after it arrives.
Use TypeScript to make the codebase easier to write, refactor, and reason about. Use JSON Schema or another runtime validator at boundaries where data comes from users, files, services, queues, webhooks, or partners. When both describe the same model, choose a source of truth and generate the other representation where practical.
The goal is not to describe data twice. The goal is to make sure the data your code believes in is the data your system actually received.
References
For deeper implementation details and specifications, start here:





