A worked example: an issue tracker response
This is two records from an issue-tracker API, shaped like the issue lists that GitHub and GitLab return and trimmed to the fields that matter here. The first issue is brand new - nobody is assigned, it has no labels or milestone, and it is still open. The second has been triaged, fixed by a pull request, and closed.
[
{
"number": 1347,
"title": "Crash when the config file is empty",
"state": "open",
"user": { "login": "mkowalski", "id": 58213 },
"assignee": null,
"labels": [],
"milestone": null,
"comments": 0,
"created_at": "2026-09-14T08:12:44Z",
"closed_at": null
},
{
"number": 1342,
"title": "Add a --quiet flag",
"state": "closed",
"user": { "login": "tnguyen", "id": 90112 },
"assignee": { "login": "mkowalski", "id": 58213 },
"labels": [{ "name": "enhancement", "color": "a2eeef" }],
"milestone": { "title": "v2.4", "due_on": "2026-10-01T00:00:00Z" },
"comments": 3,
"created_at": "2026-09-02T16:40:03Z",
"closed_at": "2026-09-09T10:05:51Z",
"pull_request": { "url": "https://api.example.com/repos/acme/cli/pulls/1342" }
}
]
The converter returns:
type Root = {
number: number;
title: string;
state: string;
user: {
login: string;
id: number;
};
assignee: {
login: string;
id: number;
} | null;
labels: {
name: string;
color: string;
}[];
milestone: {
title: string;
due_on: string;
} | null;
comments: number;
created_at: string;
closed_at: string | null;
pull_request?: {
url: string;
};
}[];
Every line of that is earned from the data. assignee, milestone, and closed_at were null on one record and populated on the other, so each became a union with null. labels was empty on the first record, so its element type comes from the second. And pull_request exists on only one record, so it is optional.
Optional and nullable are different things
The example produces both closed_at: string | null and pull_request?: {...}, and the difference is not cosmetic. They describe two different API behaviours, and TypeScript makes you handle them differently:
| In the JSON | Generated as | What it tells you |
|---|---|---|
Key present, value null on some records | closed_at: string | null | The API always sends the field and uses null for "no value yet". Check with === null. |
| Key missing from some records | pull_request?: {...} | The API omits the field entirely when it does not apply. Reading it gives undefined. |
| Both, across records | field?: T | null | The API is inconsistent. Worth raising with whoever owns it. |
Under strict mode the compiler holds you to it. With the type above, issue.milestone.title is a compile error - "'issue.milestone' is possibly 'null'" - and you have to write issue.milestone?.title or check first. That error is the reason to generate types at all: it is a bug in your code that the sample data has already told you about.
Why one record is not enough
Type inference can only describe values it has seen. Paste just the first issue on its own and the result is much less useful:
interface Root {
number: number;
title: string;
state: string;
user: {
login: string;
id: number;
};
assignee: null;
labels: unknown[];
milestone: null;
comments: number;
created_at: string;
closed_at: null;
}
assignee: null says the field can only be null, labels: unknown[] has nothing to infer from, and pull_request is missing entirely. None of that is wrong about the record you pasted - it is wrong about the API.
The practical rule: paste an array of several records, chosen to be different from each other. An open and a closed issue, a user with and without a profile photo, an order with and without a discount. Because every record in the array is merged, each extra record can only widen the type toward the truth. A single record describes that record and nothing more.
Turning the output into code you would commit
The generated type is deliberately one nested literal, so it pastes cleanly and can never refer to a name that does not exist. You do not need to split it by hand to get named types - TypeScript's indexed access types can pull every piece out of Root:
type Issue = Root[number];
type User = Issue['user'];
type Label = Issue['labels'][number];
type Milestone = NonNullable<Issue['milestone']>;
Root[number] means "the element type of this array", and NonNullable strips the | null when you want the populated shape. Rename Root to something like IssueListResponse, and when the API changes you can regenerate and paste over the one definition without touching the aliases. This exact snippet compiles under TypeScript 5 with --strict.
Two more edits are usually worth making by hand:
- Narrow string enums.
state: stringis correct but loose. If the API documents the values, tighten it tostate: 'open' | 'closed'so a typo in a comparison becomes a compile error. - Leave dates as strings. JSON has no date type, so
created_atarrives as an ISO 8601 string and is typedstring. Resist changing it toDate- that would claim a conversion thatJSON.parsenever performs.
Types are not validation
TypeScript types are erased at compile time. If the API starts sending "comments": "3" as a string, nothing checks the response against your interface at runtime, and the wrong type flows through your code until something breaks further away. For data from a service you do not control, pair the type with a runtime check - a schema library such as Zod, or a JSON Schema validated with Ajv. The JSON Schema guide covers the second approach.
Limitations worth knowing
- Types describe the sample, not the contract. A field that is
nullon every record you paste is typed as plainnull, and a field that never appears cannot be generated at all. - Empty arrays become
unknown[]when no record has any elements to infer from. - Numbers are all
number. JSON does not distinguish integers from floats, and IDs above 253 lose precision inJSON.parsebefore any type is involved. - Strings are never narrowed to literal unions. Two records with
"state": "open"and"closed"still givestring, because the converter cannot know the list is complete. - Keys that are not valid identifiers are quoted, so
"content-type"becomes"content-type": string, which is valid TypeScript but needs bracket access in code.
Other output formats
- Flatten JSON to dot notation - every key path as a flat list, for field mappings
- Generate JSONPath expressions - query paths for jq, kubectl, and JSONPath libraries
- View JSON as an indented tree - the shape at a glance, without the values
- Format or minify the JSON - whitespace only, every value kept exactly
Further reading
- Null, missing, or unknown - how Jackson, Pydantic, and Zod treat the optional and nullable fields this page generates
- JSON Schema: a practical guide to validating JSON
- Common JSON structures in REST APIs
- Understanding nested JSON objects and arrays
Written and maintained by Ashish Singh · Last updated · Changelog · Found a problem with this page? Tell me.