JSON Toolkit

JSON to TypeScript: Interfaces, Type Guards, and the Limits of Generation

Bottom line: Generating an interface from sample JSON is half the job: optional fields, null, unions, dates and big integers need a human pass, and types do no runtime validation.

Intermediate~9 min readPublished 2026-10-31Updated 2026-11-04
Cover for the JSON to TypeScript interface and type guard guide

Bottom line: generate the interface to save typing, then re-check optionality and null, keep dates and large integers as strings, and add runtime validation at the boundary; treating generated types as the contract gives you no validation at all.

A generator only sees the one sample you paste into it. Whether fields can be missing, whether values can be null, and what format dates use are all guesses — and the wrong guesses are yours to fix.

1. What the generator actually does

Paste a JSON sample into JSON to TypeScript and the interface you get back is a shape summary: it counts the keys and value types it has seen, then picks the narrowest representation available.

{
  "id": 1,
  "email": "ada@example.com",
  "profile": { "nickname": "Ada", "age": 36 },
  "tags": ["admin", "beta"],
  "createdAt": "2026-10-31T08:00:00Z"
}

The output looks roughly like this:

interface User {
  id: number;
  email: string;
  profile: { nickname: string; age: number };
  tags: string[];
  createdAt: string;
}

This compiles, but it describes an ideal value, not the real contract of the endpoint. The generator does not know whether email can be missing, whether tags can be empty, or whether createdAt always carries a time zone. It does not even know that id might be a string in other records.

So the right expectation is: the generator saves typing, the human supplies meaning.

2. Optional fields and null: ? is not | null

This is the part of generated output that gets copied most often, and the part that breaks most often.

TypeScript form Meaning Matching JSON
email: string Always present, always a string {"email":"a@b.c"}
email?: string Key may be absent; when present it cannot be null {} or {"email":"a@b.c"}
email: string | null Key is always present, value may be null {"email":null}
email?: string | null Key may be absent and the value may be null All three cases

The generator has seen one sample, so it usually outputs the first form. If the API can actually return null, the type is lying: everything compiles, and then email.toLowerCase() throws at runtime.

There are two ways to fix it:

  1. Collect several real samples that cover both "key missing" and "value is null", then let the generator infer again.
  2. After generating, change fields by hand to ?: T | null and actually handle both branches in the code.

Do not take the shortcut of marking everything ?: string. Optional means "the key may not exist", not "the value may be empty". undefined and null also behave differently in JSON.stringify: the former is omitted, the latter is kept as null.

3. Uneven array elements and union types

Arrays are the easiest structure to get wrong. If tags appears as ["admin"], [], and ["beta", 3] across three records, the generator may emit string[], (string | number)[], or unknown[], depending on which samples it saw.

A human has to decide:

  • The element types really are mixed: write (string | number)[] and narrow at the point of use.
  • Only one record is dirty: fix the data. Do not hide it with any[].
  • Empty arrays: the generator cannot infer an element type and falls back to never[] or any[], so you must fill it in.
  • Fixed-length tuples: use [string, string] only when the business truly guarantees the length; otherwise adding an element later becomes a compile error.

Object arrays also need attention when keys are uneven:

interface OrderItem {
  sku: string;
  quantity: number;
  discount?: number; // some records do not carry this field
}

Put optionality on the element type, not on the array. items?: OrderItem[] means the array itself may be absent, which is a different statement entirely.

4. Dates, large integers, enums: types JSON does not have

JSON has only strings and numbers; business meaning lives in conventions. The generator infers from literals, so correct it by hand:

JSON literal Usual generated output What it should be
"2026-10-31T08:00:00Z" string Keep string in the transport layer and convert at the point of use; never declare Date directly
1717075200000 number Use number for a millisecond timestamp and document the unit
"1234567890123456789" string Keep it a string so number cannot lose precision
1 and "1" mixed number | string Normalize to one form; do not push the union into business code
"admin" string A literal union 'admin' | 'editor', or a shared enum constant
true and "true" mixed boolean | string Fix the data source instead of generating this type

The most important rule: dates in JSON are always strings. Writing createdAt: Date is a common self-deception — JSON.parse never constructs a Date, so the runtime value is still a string and .getTime() throws. Keep the transport type as string, call new Date(value) explicitly at the boundary, and check Number.isNaN.

Large integers work the same way. Once an id exceeds 2^53, JSON.parse has already lost precision, and generating number only cements the bug into the type. The backend returns a string, the frontend keeps it a string; that is the only reliable option.

5. Generated types do no runtime validation

TypeScript types are erased at compile time. No matter how precise the interface is, it cannot stop a response of {"id": "x"} — it constrains your own code, not the bytes arriving over the network.

/**
 * Check whether a runtime value is a user object.
 * @param value The runtime value to validate
 * @returns Whether it satisfies the minimum shape of User
 */
function isUser(value: unknown): value is User {
  if (typeof value !== 'object' || value === null) {
    return false;
  }
  const candidate = value as Record<string, unknown>;
  return typeof candidate.id === 'number' && typeof candidate.email === 'string';
}

A type guard only covers the fields you write into the check. As fields multiply, hand-written guards become a second definition to maintain, drifting away from the interface.

The practical trade-off looks like this:

Approach Runtime validation Source of types Fits
Interface only None Hand-written or generated Trusted internal data, compile-time constraints
Interface plus hand-written guard Yes Two definitions that drift Few fields, clear boundary
zod / valibot Yes Types inferred from the schema External APIs, forms, config
JSON Schema plus validator Yes Types generated from the schema Cross-language contracts, documentation

If the data comes from outside, make the schema the single source of truth and derive types with z.infer or json-schema-to-ts. Then "validation passes" and "the type is correct" are the same fact, and there is no second definition to drift. For the full two-way synchronization approach, see Schema and TypeScript type synchronization.

6. Interface or type: do not argue over generated output

Generators usually emit interface, but the two are interchangeable in most situations. What actually matters is three capabilities:

  • Declaration merging: interface can be declared multiple times and merges automatically; type cannot. That helps when patching third-party types, but business models should avoid it — merging lets "extra fields" slip through the compiler unnoticed.
  • Unions and mapped types: type can express A | B, Partial<T>, and template literal types directly; interface cannot.
  • Recursion: both support it, but complex recursive shapes read more naturally as type.

A more useful team rule: use interface for object shapes, type for unions, utility types, and function types. Leave generated output at its default and spend review time on optionality and runtime validation instead.

7. Toolchain: three ways to generate

Approach Input Strength Limitation
The site's JSON to TypeScript Sample JSON Works immediately, processed locally Based on the current sample only; needs manual narrowing
quicktype JSON / JSON Schema Multi-language output, CLI integration Conservative inference, unions can be too wide
json-schema-to-typescript JSON Schema Contract-driven and reproducible Requires a trustworthy schema first

A typical quicktype invocation:

npx quicktype -s json -o user.ts user.json --just-types

--just-types emits only types and no runtime conversion code, which suits projects that already have a parsing layer. If the schema is hand-written or produced by the backend, json-schema-to-typescript is the better fit because it respects required, enum, and additionalProperties — constraints that sample inference simply cannot see.

Schema generation has its own traps, such as marking every field required or silently turning null into optional; see common JSON Schema generator pitfalls.

8. A checklist before committing generated types

Run through these five items before you commit the output:

  1. Normalize field names: do not let userId and user_id coexist; map once in a conversion layer.
  2. Re-check optionality: compare against real responses and find which fields can be missing or null.
  3. Keep dates and ids as strings: document the unit and format in the type, and parse explicitly at the point of use.
  4. Reduce any and unknown: wherever the generator emitted any, either add a type or keep unknown and add a guard.
  5. Add runtime validation: validate external data at least once; do not expect an interface to catch bad data.

If the project uses several JSON libraries (JSON.parse, superjson, lossless-json), their behavior for large integers and dates differs, so confirm it during selection. See JSON parsing library selection.

Why does the generator mark every optional field as required?

It saw a single sample, so every key that appeared is treated as always present. Provide multiple samples that cover different cases, or generate from JSON Schema — the required array there is the authoritative source.

Why does declaring Date in an interface fail at runtime?

JSON.parse produces only strings, numbers, booleans, null, objects, and arrays; it never constructs a Date. Declaring Date is a compile-time promise while the runtime value remains a string. Keep string in the transport layer and convert at the boundary.

Can generated types be used as validation?

No. TypeScript types are erased after compilation and impose nothing on runtime data. Validate external data with zod, valibot, ajv, or a hand-written type guard.

Should a large integer id be typed as number or string?

If it can exceed 2^53, it must be string. JSON.parse loses precision during parsing, and typing it as number only writes the bug into the type where it can no longer be fixed.