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 JSONGenerated asWhat it tells you
Key present, value null on some recordsclosed_at: string | nullThe API always sends the field and uses null for "no value yet". Check with === null.
Key missing from some recordspull_request?: {...}The API omits the field entirely when it does not apply. Reading it gives undefined.
Both, across recordsfield?: T | nullThe 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: string is correct but loose. If the API documents the values, tighten it to state: 'open' | 'closed' so a typo in a comparison becomes a compile error.
  • Leave dates as strings. JSON has no date type, so created_at arrives as an ISO 8601 string and is typed string. Resist changing it to Date - that would claim a conversion that JSON.parse never 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 null on every record you paste is typed as plain null, 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 in JSON.parse before any type is involved.
  • Strings are never narrowed to literal unions. Two records with "state": "open" and "closed" still give string, 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

Further reading

Written and maintained by Ashish Singh · Last updated · Changelog · Found a problem with this page? Tell me.