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.

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:
- Collect several real samples that cover both "key missing" and "value is null", then let the generator infer again.
- After generating, change fields by hand to
?: T | nulland 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[]orany[], 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:
interfacecan be declared multiple times and merges automatically;typecannot. 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:
typecan expressA | B,Partial<T>, and template literal types directly;interfacecannot. - 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:
- Normalize field names: do not let
userIdanduser_idcoexist; map once in a conversion layer. - Re-check optionality: compare against real responses and find which fields can be missing or
null. - Keep dates and ids as strings: document the unit and format in the type, and parse explicitly at the point of use.
- Reduce
anyandunknown: wherever the generator emittedany, either add a type or keepunknownand add a guard. - 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.