Use a real response sample, not a hand-written example, and convert it with a JSON-to-TypeScript tool. Then review the output for nullable fields, optional properties, unions, and arrays. A generated type describes the sample, not the API contract, so keep the source schema or runtime validation as the authority.
- One JSON sample produces one shape; optional and union fields need multiple samples or a schema.
- Generated interfaces are a starting point, not a substitute for runtime validation.
- Use unknown plus a parser for untrusted data instead of any.
- Dates usually arrive as strings; decide whether to keep them as strings or parse them explicitly.
- Keep generated types close to the API client and regenerate when the contract changes.
Generating TypeScript types from JSON is a five-second operation. Making those types safe is a review process. A generator can only describe the sample it receives; it cannot know which fields are optional, which values can be null, or which strings are actually enums.
This guide covers a workflow that produces useful types and avoids the traps that make generated code misleading.
Start with a representative sample
Do not generate from a hand-written example. Use a real response from the API, captured in a safe environment. If the response has arrays, include at least two items with different shapes. If a field is sometimes absent, include a sample where it is absent.
{
"id": 1042,
"email": "dev@example.com",
"profile": {
"displayName": "Mina",
"avatarUrl": null
},
"roles": ["admin", "billing"],
"createdAt": "2026-11-09T10:15:00Z"
}
Paste it into a JSON to TypeScript converter. A typical output is:
export interface Root {
id: number;
email: string;
profile: Profile;
roles: string[];
createdAt: string;
}
export interface Profile {
displayName: string;
avatarUrl: null;
}
That is a valid starting point, but it has two problems: avatarUrl is typed as null, and roles is a free-form string array even though the API likely allows only a few values.
A generator works by walking the parsed value and widening each observed shape into a type. It never sees the bytes that JSON.parse discarded. Duplicate keys are a good example: {"role":"user","role":"admin"} becomes { role: "admin" }, because the last key wins, so the generator cannot warn about the conflict. Numbers have the same problem before generation even starts: JSON.parse('{"id":9007199254740993}') returns 9007199254740992, and the generator faithfully types the rounded value. Capture the raw response body, not a value that has already passed through a lossy parser.
| What the sample shows | What the generator writes | What production can return |
|---|---|---|
"avatarUrl": null | avatarUrl: null | a CDN URL string |
| one array item | roles: string[] | a role added after the sample was captured |
"id": 1042 | id: number | a 64-bit ID that exceeds Number.MAX_SAFE_INTEGER |
"createdAt": "2026-11-09T10:15:00Z" | createdAt: string | the same string, but never a Date |
The first row is the most common failure, but it rarely fails at the point you expect. While the type is null, a component cannot call user.profile.avatarUrl.startsWith("https") at all, so someone widens the field to string or casts the value at the call site to unblock the build. When the API returns a URL for users who uploaded an avatar and null for everyone else, the cast compiles and the runtime throws TypeError: Cannot read properties of null (reading 'startsWith'). The type did not lie about the sample; it lied about the contract.
Review nullable and optional fields
JSON has no concept of optional properties. A field is either present or absent. TypeScript has both optional properties (?) and nullable values (| null), and they are not the same.
type User = {
id: number;
nickname?: string; // may be absent
avatarUrl: string | null; // always present, may be null
};
If you only have one sample, you cannot infer which case applies. Ask the API owner or check the schema. Do not replace null with string just to make the compiler happy; that hides a real runtime case.
The distinction is not academic. In JSON there are three different states, and each needs a different type:
| JSON state | TypeScript type | Meaning |
|---|---|---|
{"nickname": "Mina"} | nickname: string | always present, never null |
{"nickname": null} | nickname: string | null | present, value may be null |
{} | nickname?: string | may be absent entirely |
If the project enables exactOptionalPropertyTypes in tsconfig.json, the compiler also distinguishes nickname?: string from nickname?: string | undefined. Assigning an object literal with nickname: undefined to the first type fails with TS2375: Type '{ nickname: undefined; }' is not assignable to type 'User' with 'exactOptionalPropertyTypes: true'. That error is useful: it forces the boundary to decide whether an absent field is represented by omission or by an explicit undefined.
OpenAPI expresses the same states with required and nullable: true (or a type array in JSON Schema). Generate from those keywords when they exist. A single sample can only produce the first row.
Handle unions and enums
A single sample cannot reveal a union. If status is "active" in the sample, the generator will produce status: string. The API may actually allow only "active" | "paused" | "closed".
type UserStatus = "active" | "paused" | "closed";
type User = {
status: UserStatus;
};
Prefer a union of string literals over an enum unless you need runtime values. Unions are erased at compile time and produce smaller output. Validate the value at runtime when it comes from an untrusted source.
The payoff appears when the contract changes. If a mapping function switches on status, adding "archived" to UserStatus turns every non-exhaustive switch into a compiler error at the exact place that needs a new branch. Without the union, the new value falls through to the default case and the UI silently renders a blank badge. A common pattern is to finish the switch with default: { const exhaustive: never = status; return exhaustive; }, which converts an unhandled value into a compile error rather than a production surprise.
Unions can also be wrong. Some APIs treat a field as an open string namespace for forward compatibility; pinning it to today's values makes every new server-side value a breaking client change. In that case keep string and validate against an allowlist at the edge, logging unknown values instead of rejecting the whole payload. The decision is a product one: do you want the client to fail closed on unknown data, or to degrade gracefully?
If you need runtime values, a const object with as const gives you both the literal union and an iterable list, without enum's reverse-mapping and numeric-coercion quirks. A numeric enum, for example, performs no runtime validation: a JSON payload containing 99 can be cast to the enum type and flow through the application unchecked.
Arrays and tuples
A single-element array produces Item[]. That is usually correct, but check whether the API guarantees at least one item or a fixed length. For fixed shapes, a tuple is more precise:
type Coordinate = [latitude: number, longitude: number];
For arrays that can be empty, Item[] is correct. Do not add a non-empty tuple unless the contract guarantees it.
Two generated shapes deserve suspicion. An empty array ([]) gives the generator nothing to infer, so some tools emit never[]; the first items.push(user) then fails with Argument of type 'User' is not assignable to parameter of type 'never'. Other tools emit any[], which silently disables checking. Both are symptoms of a sample that did not exercise the field. A mixed array such as [1, "two", null] becomes (number | string | null)[], which is honest but painful; if the API actually returns a tagged union of objects, capture one sample per variant and model a discriminated union instead of a primitive soup.
Tuples also have sharp edges. A tuple type enforces length, so const pair: [number, number] = [1] fails with TS2322. Optional tuple elements ([number, number?]) and rest elements ([string, ...number[]]) cover variable-length cases, but they are not inferred from JSON. If the value is a fixed coordinate or a pair of IDs, write the tuple by hand and add a runtime length check at the boundary.
Dates and numbers
JSON numbers can be integers, floats, or values that exceed JavaScript's safe integer range. A generator typically maps all numbers to number. If the API uses 64-bit identifiers, number may lose precision.
// A large ID may lose precision as a JavaScript number
type Order = {
id: string; // prefer string at the boundary for 64-bit IDs
};
Dates arrive as strings. Keep them as strings at the boundary, and parse them where the application needs date arithmetic:
type Order = {
createdAt: string; // ISO 8601
};
const createdAt = new Date(order.createdAt);
Validate the date string before parsing; new Date("not-a-date") produces an invalid date rather than throwing.
The exact behavior is worth memorizing because it varies by string shape. new Date("2026-11-09") is parsed as UTC midnight, new Date("2026-11-09T10:15:00") is parsed as local time in every modern engine, and new Date("2026-11-09T10:15:00Z") is unambiguous. A date-only string in a US timezone therefore renders as the previous day for users west of UTC. Invalid dates fail later: new Date("not-a-date").toISOString() throws RangeError: Invalid time value, while Number.isNaN(date.getTime()) is the safe check. Calendar overflow is not rejected either: new Date("2026-02-30") rolls over to 2026-03-02, so a typo in a billing period silently becomes a different month. Compare the formatted year, month, and day back to the input when the value drives money or compliance.
Large integers need a decision before parsing, not after. Number.MAX_SAFE_INTEGER is 9007199254740991; the JSON text {"id":9007199254740993} parses to 9007199254740992, and no amount of TypeScript will recover the lost digit. If the API documents 64-bit IDs, keep them as strings in the transport type and use BigInt(id) only inside the code that needs arithmetic. JSON.parse cannot be configured to preserve integer text; use a lossless parser or ask the API for strings.
| Value in JSON | JSON.parse result | Safe to type as number? |
|---|---|---|
1042 | 1042 | yes |
9007199254740993 | 9007199254740992 | no, precision lost |
1e400 | Infinity | no, not valid JSON number semantics |
-0 | -0 | yes, but Object.is(-0, 0) is false |
1.0 | 1 | yes, the decimal form is lost |
The last two rows rarely matter, but they explain why a generated type cannot be treated as a schema: JSON numbers do not carry integer-versus-float or precision metadata.
Untrusted data: unknown, not any
Generated types describe the expected shape. They do not validate it. When data comes from a network request, a file, or user input, start with unknown and narrow it:
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
typeof (value as User).id === "number" &&
typeof (value as User).email === "string"
);
}
const parsed: unknown = JSON.parse(text);
if (!isUser(parsed)) throw new Error("Invalid user payload");
For larger contracts, use a schema validator. The JSON Schema validator can check the payload against a schema, and the JSON to JSON Schema generator can produce a first draft from a sample.
The hand-written guard above is intentionally incomplete: it checks id and email but not roles, profile, or the date format, and the as User casts would hide a mismatch inside the object. A real guard should check every property it later relies on, use Object.hasOwn(value, "email") when presence matters, and reject arrays, because typeof [] === "object" passes the first check. With Zod, safeParse returns a structured failure such as { success: false, error: { issues: [{ code: "invalid_type", path: ["id"], expected: "number", received: "string" }] } }. Log the issue path, not just "invalid payload", or the next person will re-debug the same field.
One more runtime hazard is merging. JSON.parse does not execute the __proto__ setter, but a later Object.assign(target, parsed) or a recursive merge can. If the parsed object is untrusted, validate the shape first and copy only known keys; do not spread arbitrary JSON into configuration objects or ORM models.
Keep generated types close to the client
Generated types should live next to the API client that uses them, not in a global types folder. When the API changes, the type and the request code change together.
src/api/users/
client.ts
types.generated.ts
users.test.ts
Add a regeneration command to the repository and review the diff like any other code change. A generated file that nobody reviews is a future runtime bug.
Two process details make review practical. First, make generation deterministic: pin the generator version, sort keys and interfaces, and avoid writing a timestamp into the file header. Otherwise every run produces a diff full of noise and reviewers stop reading it. Second, make staleness visible in CI with git diff --exit-code -- src/api/users/types.generated.ts; a failure means someone changed the sample or the generator without committing the output. Neither check catches a server-side field change, because the generated type is not connected to the server at compile time. That gap is why the contract test in the next section matters.
| Placement | Good for | Tradeoff |
|---|---|---|
| Next to the API client | One owner, changes together | Duplicated types if several clients need the same model |
| Shared package | Multiple apps consume one contract | Versioning and release overhead |
Global types/ folder | Small repositories | Unclear ownership and stale copies |
For a monorepo, a shared package generated from the published OpenAPI document is usually the cleanest boundary; for a single app, keep the file in the client folder and resist the urge to centralize it prematurely.
Validate the contract, not just the sample
If the API publishes OpenAPI or JSON Schema, generate from that instead of from a sample. The schema includes required fields, allowed values, formats, and constraints that a sample cannot express.
If there is no schema, capture more samples and compare them:
- A normal response.
- A response with optional fields absent.
- An error response, which may have a different shape.
- A response with an empty array.
- A response with a large number or a long string.
The union of these samples produces a better type than a single happy-path response.
When a published schema exists, generate with a tool that understands it, such as openapi-typescript for OpenAPI documents or json-schema-to-typescript for JSON Schema. These tools translate required, enum, oneOf, and additionalProperties into TypeScript constructs that a sample cannot express. They still cannot enforce format: date-time or numeric ranges at runtime, so keep a validator for untrusted input.
Schema drift is the remaining risk. The document says a field is required, the deployed server omits it, and the generated type is confidently wrong. Add a contract test that calls the real API, validates the response with the same schema, and fails the build when validation fails. In organizations where the API team owns the schema, publish it as an artifact and pin the client generator to a version; fetching the schema from a main branch at build time makes yesterday's build unreproducible today.
A review checklist
- Are nullable fields modeled as
T | null? - Are optional fields modeled with
?only when they can be absent? - Are enums represented as literal unions or validated strings?
- Are large numeric IDs kept as strings?
- Are dates kept as strings at the boundary?
- Is untrusted data validated at runtime?
- Is the generated file committed with a regeneration command?
- Is there a test that fails when the API shape changes?
A generated type is a snapshot of one sample. Treat it as documentation that the compiler can check, not as a guarantee about the API. The JSON formatter helps you inspect the sample, and the converter gets you to a useful first draft; the review is what makes it safe.
Run the review as a short verification pass: regenerate and confirm the diff is limited to the fields you expected; run tsc --noEmit with the project's strict flags; call the real endpoint and validate the response against the schema; search for as casts and any on the new types; and confirm the contract test fails when you deliberately rename a field in a fixture. If all five pass, the type is as trustworthy as the sample it came from.
Generated types are the wrong tool in three situations: a throwaway script that only reads two fields, an API that changes shape daily before it stabilizes, and binary or streaming payloads that never become JSON objects. In those cases, a narrow local type or a runtime parser is cheaper than maintaining a generated contract. The recommendation is also wrong when the organization already has a typed client generated from a protocol definition (GraphQL, gRPC, Protobuf). Duplicating that contract as hand-maintained TypeScript creates two sources of truth that will diverge.
Frequently asked questions
Can a JSON-to-TypeScript tool infer optional properties?
Only if it sees multiple samples where the property is present in some and absent in others, or if you provide a JSON Schema with a required list. A single sample cannot distinguish an optional property from a required one.
Should generated types use any or unknown?
Use unknown for values that are not yet validated. any disables type checking and spreads through the codebase. Convert unknown to a concrete type only after a runtime check.
How do I handle null values in JSON?
Model nullable fields explicitly, for example string | null. Do not silently drop null from the union unless the API guarantees it will never appear. Optional and nullable are different: a field can be present with a null value.
Why are my date fields typed as string?
JSON has no date type. Dates are serialized as strings, usually ISO 8601. Keep them as strings at the boundary and parse them into Date objects only when the application needs date arithmetic.
Should I generate types from JSON or from OpenAPI?
Prefer the source contract, such as OpenAPI or JSON Schema, when it exists. Generate from a JSON sample only when no schema is available, and treat the result as a snapshot that must be reviewed.