Turning a JSON sample into a TypeScript interface takes seconds with quicktype or an online converter. The thing worth understanding before you rely on it: a TypeScript interface is erased at compile time, so it makes no promise about what actually arrives. response.json() returns any, you assert a shape onto it, and if the API changes you get undefined is not an object in production with types that still compile cleanly.
So this article is half about generating the types and half about the step almost everyone skips, which is making the type a claim you have actually checked.
Table of contents
- Generating the interface
- Where a single sample gets it wrong
- The runtime gap
- Closing it with a schema
- Where validation belongs, and where it does not
- Keeping generated types current
- How this fits the rest of the stack
- FAQ
Generating the interface
For a one-off, a converter in the browser is fine. For anything in a repository, use the CLI so it can be regenerated.
npx quicktype --lang typescript --src-lang json \
--just-types --prefer-unions \
-o src/types/api.ts sample.json
# From a live endpoint
curl -s https://api.example.com/users/1 \
| npx quicktype --lang typescript --just-types -o src/types/user.ts
--just-types omits the runtime marshalling code quicktype can generate, which most projects do not want. --prefer-unions produces "a" | "b" rather than string where it can infer an enum.
If the API publishes an OpenAPI document, use that instead of a sample. It carries optionality and nullability that a single JSON sample cannot express:
npx openapi-typescript https://api.example.com/openapi.json -o src/types/api.ts
Where a single sample gets it wrong
Generation infers from one example, so it encodes accidents of that example as facts.
- Optional fields — absent in your sample, so not in the type. Present in production, or worse, present in your sample and absent later.
- Nullable fields —
"middleName": nullbecomesnull, a type inhabited only by null, rather thanstring | null. - Empty arrays —
[]givesany[], because there is nothing to infer from. - Numbers — no distinction between integer and float, and a large ID may exceed the safe integer range.
- Dates —
"2026-08-13T10:00:00Z"isstring, and stays a string afterJSON.parseunless you convert it. - Union members — a polymorphic response sampled once gives you one variant.
Feed several samples covering the variation you know about:
npx quicktype --lang typescript --just-types \
-o src/types/user.ts samples/*.json
This helps. It still cannot tell you about a field that appears next month.
The runtime gap
This is the part that matters. TypeScript types vanish at compile time; fetch gives you any.
interface User {
id: number;
name: string;
email: string;
}
// This compiles and is a lie
const user = await fetch('/api/user/1').then(r => r.json()) as User;
// If the API renamed `name` to `fullName`, this is undefined at runtime
console.log(user.name.toUpperCase()); // TypeError, in production
The as User is an assertion, not a check. You told the compiler to stop asking questions. Everything downstream is typed on the strength of an assumption nobody verified.
This is why generated types feel safe and are not. The boundary between your program and the outside world is exactly where types stop being enforced, and it is exactly where generated types get used.
Closing it with a schema
Define the shape once as a runtime schema and derive the TypeScript type from it. One definition, checked at runtime, typed at compile time.
import { z } from 'zod';
const User = z.object({
id: z.number().int(),
name: z.string(),
email: z.string().email(),
middleName: z.string().nullable().optional(),
createdAt: z.coerce.date(), // string -> Date
role: z.enum(['admin', 'member', 'viewer']),
});
type User = z.infer<typeof User>; // the TS type, derived
async function getUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return User.parse(await res.json()); // throws on mismatch, here
}
Now a changed API fails at the fetch, with a message naming the field and what was expected, instead of failing three layers deeper as an undefined property.
safeParse when you would rather handle it than throw:
const result = User.safeParse(await res.json());
if (!result.success) {
logger.warn({ issues: result.error.issues }, 'user payload changed');
return null;
}
return result.data;
You can generate the schema too — quicktype targets Zod, and json-schema-to-zod converts an existing JSON Schema. That gives you generation and runtime checking rather than one or the other.
Where validation belongs, and where it does not
Validating every object everywhere is overhead nobody enjoys. The rule that holds up: validate at the boundary, trust internally.
- Validate — HTTP responses from services you do not control, request bodies from clients, webhook payloads, values read from files or queues, anything out of a cache that survives deploys.
- Do not validate — data flowing between your own functions, values already parsed at the boundary, internal function arguments the compiler is checking.
Webhooks deserve special mention. They are unauthenticated by shape, arrive from a system you do not control, and often carry the least-tested payloads in your integration. Parsing a webhook body with a schema is one of the highest-value validations you can add, and it costs about six lines.
Keeping generated types current
A generated file that is edited by hand stops being generated. Two habits keep it honest.
- Put a header comment saying the file is generated and how to regenerate it, and add the command as an npm script.
- In CI, regenerate and fail if the output differs from what is committed. That turns an upstream API change into a failed build rather than a runtime error next Tuesday.
{
"scripts": {
"types:gen": "openapi-typescript https://api.example.com/openapi.json -o src/types/api.ts",
"types:check": "npm run types:gen && git diff --exit-code src/types/api.ts"
}
}
The types:check script is the useful half. Without it, a generated types file drifts from reality quietly, and the whole exercise gives you confidence without accuracy.
The same discipline applies at the other end: when the API is yours, the schema should be generated from one source rather than maintained in two places. Deploying the API as a service from your repository, with build logs that fail the deploy when the type check does, is what keeps the contract and the code from separating — the failing build and the failing request end up in the same place on RunxBuild rather than in two systems you have to correlate by timestamp.
How this fits the rest of the stack
Generate the interface with quicktype or openapi-typescript, feed it more than one sample, and know that the result is a hypothesis. Close the gap with a runtime schema — Zod or similar — at every boundary where data enters your program, and derive the TypeScript type from the schema so there is one definition rather than two.
Add a CI check that regenerates and diffs, so an upstream change breaks the build rather than a user’s session. If you are working out what the service and database behind that API cost to run, the RunxBuild hosting calculator shows them as separate line items.
Useful related references:
- SyntaxError Unexpected Token: The Five Things It Actually Means in JavaScript and TypeScript
- Should package-lock.json Be Committed?
- JSON to YAML: Tools, When to Convert, and the Gotchas
- Services on RunxBuild
FAQ
How do I convert JSON to a TypeScript interface?
Use npx quicktype --lang typescript --just-types -o types.ts sample.json, or paste into an online converter for a one-off. If the API publishes an OpenAPI document, openapi-typescript is better, because the spec carries optionality and nullability that a single JSON sample cannot express.
Do TypeScript interfaces validate JSON at runtime?
No. Types are erased during compilation, so as User on a fetch result is an assertion rather than a check. If the response shape changes, the code still compiles and fails at runtime with an undefined property. Runtime validation needs a schema library such as Zod.
Why are my generated types wrong for optional fields?
Generation infers from the sample it was given, so a field missing from that sample is missing from the type, and a field that happened to be null becomes type null rather than string | null. Supply several samples covering the real variation, or generate from an OpenAPI schema instead.
What is the best way to type an API response in TypeScript?
Define a runtime schema once and derive the static type from it with z.infer. You get one definition, a compile-time type, and a runtime check that fails at the boundary with a message naming the offending field, rather than failing later as an undefined property.
Should I validate every API response?
Validate at boundaries — external HTTP responses, request bodies, webhook payloads, values read from files or queues. Do not validate data passing between your own functions, where the compiler is already checking it. Webhooks in particular are worth validating, since they arrive from systems you do not control.