TypeScript Tips: Types from JSON, Schema, and API Responses
Every TypeScript developer has done it: staring at an API response in the Network tab, then manually writing out a type definition property by property. It is tedious, error-prone, and completely unnecessary. There are faster ways, and understanding them will save you hours on every project.
In this article, I will cover how to automatically generate TypeScript types from JSON data, how to handle the tricky edge cases that trip up automated tools, and how to keep your types in sync with the APIs they represent.
The Problem: Manual Types for API Responses
Consider a typical API response from a user endpoint:
{
"id": 42,
"name": "Alice Chen",
"email": "[email protected]",
"avatar_url": "https://cdn.example.com/avatars/42.jpg",
"created_at": "2026-01-15T08:30:00Z",
"settings": {
"theme": "dark",
"notifications": true,
"language": "en"
},
"roles": ["admin", "editor"],
"last_login": null
}
Writing the type by hand, you would produce something like this:
interface User {
id: number;
name: string;
email: string;
avatar_url: string;
created_at: string;
settings: {
theme: string;
notifications: boolean;
language: string;
};
roles: string[];
last_login: string | null;
}
That took a couple of minutes for one simple object. Now imagine an API with 30 endpoints, each returning different shapes. Or a single response with deeply nested objects. The manual approach does not scale.
JSON to TypeScript: Automatic Conversion
Automatic JSON-to-TypeScript conversion analyzes a JSON sample and infers the type structure. The JSON to TypeScript converter does this instantly: paste your JSON, get a TypeScript interface.
The conversion logic follows these rules:
- JSON
stringmaps to TypeScriptstring - JSON
number(integer or float) maps to TypeScriptnumber - JSON
booleanmaps to TypeScriptboolean - JSON
nullmaps tonull(the field is typed asT | null) - JSON objects become nested interfaces
- JSON arrays infer their element type from the contents
Handling Edge Cases
Automated conversion gets you 80% of the way there. The remaining 20% requires human judgment. Here are the cases I encounter most often.
Nullable Fields
If a field is null in your sample, the converter only knows it can be null. It cannot infer the non-null type. You need to check the API docs or look at another response where the field is populated.
// Converter output (from a null sample)
interface User {
last_login: null; // Too narrow!
}
// Corrected by hand
interface User {
last_login: string | null; // ISO 8601 date or null
}
Optional Properties
A field that is missing from the JSON is different from a field that is null. If certain fields only appear in some responses, mark them as optional:
interface User {
id: number;
name: string;
email: string;
phone?: string; // Not always present
avatar_url?: string; // Not always present
}
The converter cannot know which fields are optional from a single sample. Comparing multiple response samples, or better yet, referencing the API schema, is the reliable approach.
Date Strings
JSON has no date type. Dates are transmitted as strings, usually in ISO 8601 format. The converter will type them as string, which is technically correct but loses semantic meaning. I typically create a branded type or at least add a comment:
// Option 1: Type alias for clarity
type ISODateString = string;
interface Event {
id: number;
title: string;
starts_at: ISODateString;
ends_at: ISODateString;
}
// Option 2: Branded type for stricter checking
type ISODateString = string & { __brand: 'ISODate' };
function parseDate(iso: ISODateString): Date {
return new Date(iso);
}
Nested Arrays of Objects
Complex responses often contain arrays of objects, which may themselves contain arrays. The converter handles this by creating separate interfaces for each nested shape:
// JSON input
{
"orders": [
{
"id": 1,
"items": [
{ "product_id": 10, "quantity": 2, "price": 29.99 }
]
}
]
}
// Generated TypeScript
interface OrderItem {
product_id: number;
quantity: number;
price: number;
}
interface Order {
id: number;
items: OrderItem[];
}
interface RootObject {
orders: Order[];
}
You can format your JSON with the JSON Formatter before conversion to make the structure easier to verify visually.
JSON Schema to Types
If the API provides a JSON Schema definition (many OpenAPI/Swagger APIs do), you can generate types from the schema rather than from sample data. This is more reliable because the schema defines the full contract, including optional fields, nullable types, enums, and validation constraints.
// JSON Schema (Draft 7)
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"id": { "type": "integer" },
"status": {
"type": "string",
"enum": ["active", "inactive", "suspended"]
},
"metadata": {
"oneOf": [
{ "type": "object", "properties": { "source": { "type": "string" } } },
{ "type": "null" }
]
}
},
"required": ["id", "status"]
}
// Generated TypeScript
interface Root {
id: number;
status: 'active' | 'inactive' | 'suspended';
metadata?: { source?: string } | null;
}
The Schema to Code generator handles JSON Schema Draft 7 features including $ref resolution (following references to shared definitions), oneOf/anyOf conversion to union types, enum to literal unions, and required vs optional property distinction.
For validating your schema before generating types, the JSON Schema Validator will catch structural errors.
Real Workflow: From API to Types
Here is the workflow I use when integrating a new API:
- Hit the endpoint. Use cURL or the API tester to get a real response.
- Format the JSON. Paste it into the JSON Formatter to see the full structure clearly.
- Generate the base type. Paste the formatted JSON into the JSON to TypeScript converter.
- Refine the type. Add optionality, fix nullables, add branded types for dates and IDs.
- Check against schema. If the API has a JSON Schema or OpenAPI spec, cross-reference your type to catch missing fields.
- Add a type guard. Write a runtime check for the most critical types.
Type Narrowing and Type Guards
Generated types are compile-time only. At runtime, the data from an API could be anything. Type guards bridge this gap:
interface User {
id: number;
name: string;
email: string;
}
// Type guard function
function isUser(data: unknown): data is User {
return (
typeof data === 'object' &&
data !== null &&
typeof (data as User).id === 'number' &&
typeof (data as User).name === 'string' &&
typeof (data as User).email === 'string'
);
}
// Usage
const response = await fetch('/api/user/42');
const data: unknown = await response.json();
if (isUser(data)) {
// TypeScript knows data is User here
console.log(data.name);
} else {
throw new Error('Unexpected API response shape');
}
Writing type guards by hand is verbose. For complex types, runtime validation libraries are a better fit (more on that below).
Utility Types: Your TypeScript Power Tools
TypeScript ships with utility types that let you derive new types from existing ones. These are essential when working with API types.
interface User {
id: number;
name: string;
email: string;
avatar_url: string;
created_at: string;
}
// Partial: all properties become optional
// Useful for PATCH/update requests
type UserUpdate = Partial<User>;
// { id?: number; name?: string; email?: string; ... }
// Required: all properties become required
type StrictUser = Required<User>;
// Pick: select specific properties
// Useful for list views that show less data
type UserSummary = Pick<User, 'id' | 'name' | 'avatar_url'>;
// { id: number; name: string; avatar_url: string }
// Omit: exclude specific properties
// Useful for create requests (server assigns id)
type CreateUserPayload = Omit<User, 'id' | 'created_at'>;
// { name: string; email: string; avatar_url: string }
// Record: typed key-value maps
type UserPermissions = Record<string, boolean>;
// { [key: string]: boolean }
// Combining utility types
type UserFormData = Partial<Omit<User, 'id' | 'created_at'>>;
// All editable fields, all optional
Zod and Runtime Validation
Zod is a TypeScript-first schema validation library that serves as both a runtime validator and a type generator. Instead of writing types and type guards separately, you define a Zod schema and infer the TypeScript type from it:
import { z } from 'zod';
// Define the schema (this IS your runtime validator)
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
avatar_url: z.string().url().optional(),
created_at: z.string().datetime(),
roles: z.array(z.enum(['admin', 'editor', 'viewer'])),
last_login: z.string().datetime().nullable(),
});
// Infer the TypeScript type from the schema
type User = z.infer<typeof UserSchema>;
// No need to write the type separately!
// Runtime validation
const response = await fetch('/api/user/42');
const json = await response.json();
const user = UserSchema.parse(json);
// Throws ZodError if shape doesn't match
// Otherwise, user is typed as User
// Safe parsing (doesn't throw)
const result = UserSchema.safeParse(json);
if (result.success) {
console.log(result.data.name); // typed as User
} else {
console.error(result.error.issues);
}
The Zod approach eliminates the duplication between types and validation. The schema is the single source of truth. This is especially powerful in full-stack TypeScript projects where the same schema can validate both API inputs on the server and API outputs on the client.
Best Practices for API Type Safety
- Never use
anyfor API responses. At minimum, type them asunknownand narrow from there. - Centralize your API types. Keep all generated types in a single
types/api.tsfile or atypes/directory. This makes it easy to regenerate when the API changes. - Version your types. If the API is versioned, your types should be too.
UserV1andUserV2can coexist during migrations. - Use
OmitandPickto derive request types from response types, rather than defining them separately. This ensures consistency. - Add runtime validation at the boundary. The fetch call is the boundary between trusted (your code) and untrusted (the network). Validate there.
- Automate regeneration. If the API has an OpenAPI spec, set up a script to regenerate types on each release. Manual sync will always fall behind.
Getting your API types right is one of the highest-leverage things you can do in a TypeScript project. It catches bugs at compile time, provides autocompletion in your editor, and serves as living documentation of your data model. Start with the JSON to TypeScript converter for quick conversions, use the Schema to Code generator when you have formal schemas, and validate your data with the JSON Schema Validator to keep everything consistent.