Generate TypeScript Interfaces from JSON (Without the Guesswork)
You have a JSON response. You need TypeScript types. It feels like this should take thirty seconds, and sometimes it does.
Then the payload contains a nested object, an empty array, a suspicious date string, and one field that is null for reasons nobody documented. Suddenly, the thirty-second job has opinions.
Let's turn a realistic JSON response into clean TypeScript interfaces, talk about the small decisions that actually matter, and fetch the result without pretending TypeScript can validate data at runtime. No giant setup. No type-system gymnastics.
Generate TypeScript interfaces from a JSON response
- Inspect the JSON shapes Check which values are objects, arrays, nullable, or numeric-looking strings before writing any types.
- Model the wire format Write an interface that matches the response exactly, including snake_case keys and nested objects as their own named types.
- Distinguish null from optional Use `string | null` for a key that is always present but may be empty, and `?:` for a key that may be missing entirely.
- Keep dates as strings response.json() never produces a Date — keep created_at: string in the response type and convert only at the app boundary if needed.
- Fetch and check response.ok fetch() resolves normally on HTTP errors, so check response.ok before parsing the body.
- Validate at the boundary A type assertion is not a runtime check. Parse as unknown and use a type guard or schema validator for untrusted or important data.
Start with the actual JSON
Imagine an API returns this project:
{
"id": "project_42",
"name": "Launch the new site",
"is_public": false,
"created_at": "2026-07-30T14:18:00Z",
"owner": {
"id": 7,
"display_name": "Mina Patel",
"avatar_url": null
},
"tags": ["typescript", "api"],
"stats": {
"views": 1284,
"stars": 91
}
}
Before writing anything, take a quick look at the shapes:
- The root value is an object.
- owner and stats are nested objects.
- tags is an array of strings.
- avatar_url can be null.
- created_at looks like a date, but JSON only gives us a string.
- The API uses snake case, so we need to decide whether our TypeScript model should match it or transform it.
That little check saves a surprising amount of cleanup later.
Make an interface that matches the wire format
The simplest option is to describe the response exactly as it arrives:
interface ProjectResponse {
id: string;
name: string;
is_public: boolean;
created_at: string;
owner: ProjectOwnerResponse;
tags: string[];
stats: ProjectStatsResponse;
}
interface ProjectOwnerResponse {
id: number;
display_name: string;
avatar_url: string | null;
}
interface ProjectStatsResponse {
views: number;
stars: number;
}
That is already useful. Editors can autocomplete the response, refactors become safer, and a typo like project.isPublic is caught because the API actually returned is_public.
TypeScript calls these object types: descriptions of the properties a value is expected to have. Interfaces are a natural fit here, although a type alias would also work. The TypeScript Handbook has the full story on object types and interfaces.
null and optional are not the same thing
This is a tiny distinction with a talent for causing bugs.
avatar_url: string | null;
This says the key is present, but its value may be null.
avatar_url?: string;
This says the key itself may be missing.
And yes, an API can do both:
avatar_url?: string | null;
Use the version that matches the API contract, not just the one sample sitting in front of you. If you only have sample data, check a few responses before declaring every field required.
Dates are still strings after response.json()
It is tempting to write this:
created_at: Date;
But parsing JSON does not magically create a JavaScript Date. The value remains a string. Keeping created_at: string in the response interface is honest and makes the boundary clear.
If the rest of your app would rather use camel case and real dates, create a separate application model:
interface Project {
id: string;
name: string;
isPublic: boolean;
createdAt: Date;
owner: {
id: number;
displayName: string;
avatarUrl: string | null;
};
tags: string[];
stats: {
views: number;
stars: number;
};
}
function toProject(response: ProjectResponse): Project {
return {
id: response.id,
name: response.name,
isPublic: response.is_public,
createdAt: new Date(response.created_at),
owner: {
id: response.owner.id,
displayName: response.owner.display_name,
avatarUrl: response.owner.avatar_url,
},
tags: response.tags,
stats: response.stats,
};
}
Now the API shape and your app's preferred shape are separate. That may look like a little extra code, but it keeps changes at the network boundary instead of spreading snake-case properties and date parsing across the whole project.
Fetch the JSON
Here is the straightforward version:
async function getProject(id: string): Promise<ProjectResponse> {
const response = await fetch(`/api/projects/${encodeURIComponent(id)}`);
if (!response.ok) {
throw new Error(`Project request failed with ${response.status}`);
}
return (await response.json()) as ProjectResponse;
}
Checking response.ok matters because fetch() can resolve normally for HTTP errors such as 404 or 500. The ok property is true for status codes from 200 through 299. MDN's Fetch guide and Response.ok reference cover that behavior.
Nice. Done, right?
Almost.
The interface does not validate the response
This line is a promise you make to the compiler:
const project = (await response.json()) as ProjectResponse;
It is not a runtime check. If the server sends "views": "a lot", the type assertion does not convert the value or throw an error. Type assertions are removed during compilation, so an incorrect assertion cannot protect the running application. The TypeScript Handbook is refreshingly direct about this in Everyday Types.
For a trusted internal API, you may decide that the assertion is an acceptable boundary. For public, unstable, or especially important data, parse the response as unknown and validate it with a schema library or a type guard before using it.
A small guard might begin like this:
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isProjectResponse(value: unknown): value is ProjectResponse {
if (!isRecord(value)) return false;
if (!isRecord(value.owner) || !isRecord(value.stats)) return false;
return (
typeof value.id === "string" &&
typeof value.name === "string" &&
typeof value.is_public === "boolean" &&
typeof value.created_at === "string" &&
Array.isArray(value.tags) &&
value.tags.every((tag) => typeof tag === "string") &&
typeof value.owner.id === "number" &&
typeof value.owner.display_name === "string" &&
(typeof value.owner.avatar_url === "string" ||
value.owner.avatar_url === null) &&
typeof value.stats.views === "number" &&
typeof value.stats.stars === "number"
);
}
Then use it at the boundary:
const data: unknown = await response.json();
if (!isProjectResponse(data)) {
throw new Error("The project response has an unexpected shape");
}
const project = toProject(data);
TypeScript narrows data from unknown to ProjectResponse after the guard succeeds. That is the useful difference between describing data and actually checking it. See the Handbook chapter on narrowing.
For a large payload, hand-writing every check gets old quickly. A runtime schema validator is usually more maintainable. The important part is simply knowing when static interfaces are enough for your risk level — and when they are not.
Generate the first draft on macOS
You do not need to type every property by hand. JsonXmlEditor for macOS can take a JSON payload and generate a TypeScript DTO as a starting point:
- Paste the JSON or send the request in the built-in REST workspace.
- Format and validate the response.
- Open the DTO generator and select TypeScript.
- Generate the interfaces.
- Review nullability, empty arrays, dates, and fields that may be missing.
- Add runtime validation if the response cannot be trusted.
The review step is the bit you should not skip. A generator sees the sample you gave it, not the entire API contract. An empty array cannot reveal its element type, one non-null value does not prove a field is always present, and a timestamp still arrives as a string.
Common generation traps
Keep an eye on these when reviewing generated interfaces:
- Empty arrays: [] gives the generator no clue whether the real type is string[], User[], or something else.
- One-off literal values: A sample status of "draft" does not prove the only valid type is the literal "draft".
- Nullable fields: Test more than one response before removing null or optional markers.
- Mixed arrays: If an endpoint mixes unrelated shapes in one array, you may need a union type and a reliable discriminator.
- Dates: ISO-looking strings are still strings until your code converts and validates them.
- Huge anonymous objects: Give nested concepts names. ProjectOwnerResponse is easier to understand and reuse than a wall of inline properties.
- Overusing any: any makes errors disappear by switching off checking. Prefer a real type or unknown at an untrusted boundary.
A quick pre-commit checklist
Before calling the generated interfaces finished, ask:
- Does the root JSON value match the interface: object or array?
- Did I separate null from a missing property?
- Are numeric strings modeled as strings?
- Did I keep dates as strings at the network boundary?
- Have I checked more than one realistic response?
- Does this endpoint need runtime validation?
- Would named nested interfaces make the model easier to read?
If those answers look good, you are in solid shape. Generate the boring first draft, spend your time reviewing the assumptions, and let TypeScript help with the rest.
You can try the JSON-to-TypeScript workflow free in JsonXmlEditor on the Mac App Store. Working with Swift too? The related guide From REST Response to Swift Codable on macOS walks through the same boundary from the Apple side.
Frequently asked questions
- How do I generate TypeScript interfaces from a JSON response?
- Write an interface that mirrors the response exactly — same keys, same nesting — then decide separately whether your app should transform it (camelCase, real Date objects) at the network boundary. A tool like JsonXmlEditor can generate the first draft, but review nullability, dates, and empty arrays before trusting it.
- What is the difference between `string | null` and `string?` in TypeScript?
- `string | null` means the key is always present but its value can be null. `field?: string` means the key itself may be missing from the object entirely. An API can combine both as `field?: string | null`.
- Does JSON.parse or response.json() turn a date string into a Date object?
- No. Both return the value as a plain string. Keep the field typed as `string` in your response interface, and convert it with `new Date(...)` only if your application layer needs an actual Date.
- Does a TypeScript interface validate data at runtime?
- No. A type assertion like `as ProjectResponse` is removed at compile time and performs no runtime check. For untrusted or important responses, parse as `unknown` and validate with a type guard or a schema library before use.
- Can I generate TypeScript types from a JSON API response on Mac?
- Yes. JsonXmlEditor can send the REST request, format and validate the response, and generate TypeScript interfaces locally. Treat the output as a first draft and check nullability and dates against more than one real response.