JSON to TypeScript: Generate Types from API Responses
If you've ever called a REST API in TypeScript and typed the response as any, you know the pain that follows. Autocomplete vanishes. Typos sneak through. A field you thought was a string turns out to be null, and your app crashes in production.
The fix? Convert your JSON responses into proper TypeScript types. In this guide, we'll walk through manual mapping, automated tooling, and the best practices that keep your API integrations rock-solid.
Why TypeScript Types from JSON Matter
TypeScript's type system exists to catch mistakes before they reach your users. When you model an API response with accurate types, three things happen:
- Autocomplete works everywhere. Your editor knows the shape of the data, so you stop guessing field names.
- Refactors become safe. Rename a property and the compiler tells you every place that breaks.
- Runtime errors drop. You handle
null,undefined, and unexpected shapes at the boundary instead of deep inside your app.
Without types, you're effectively writing JavaScript with extra steps. With types, you get a living contract between your code and the data it consumes.
Manual JSON-to-TypeScript Mapping
Let's start with the fundamentals. Given a JSON object, how do you turn it into a TypeScript interface?
Basic Objects
Here's a simple user response from an API:
{
"id": 42,
"username": "jdoe",
"email": "jdoe@example.com",
"isActive": true
}
The corresponding TypeScript interface:
interface User {
id: number;
username: string;
email: string;
isActive: boolean;
}
Each JSON value maps to a TypeScript primitive: number, string, or boolean. Object keys become property names.
Nested Objects
Real APIs rarely return flat structures. Consider a user with an address:
{
"id": 42,
"username": "jdoe",
"address": {
"street": "123 Main St",
"city": "Springfield",
"zipCode": "62704",
"country": "US"
}
}
Break nested objects into their own interfaces:
interface Address {
street: string;
city: string;
zipCode: string;
country: string;
}
interface User {
id: number;
username: string;
address: Address;
}
This keeps each type focused and reusable. If another endpoint also returns an Address, you use the same interface.
Arrays
When a field contains an array, you annotate the element type:
{
"id": 42,
"username": "jdoe",
"roles": ["admin", "editor"],
"posts": [
{ "id": 1, "title": "Hello World" },
{ "id": 2, "title": "TypeScript Tips" }
]
}
interface Post {
id: number;
title: string;
}
interface User {
id: number;
username: string;
roles: string[];
posts: Post[];
}
Use Type[] syntax for simple cases. For complex generics, Array<Type> can be clearer, but consistency matters more than which you pick.
Optional Fields
APIs often return fields that may or may not be present. Mark them with ?:
// Sometimes the API returns bio, sometimes it doesn't
{
"id": 42,
"username": "jdoe",
"bio": "Software developer"
}
interface User {
id: number;
username: string;
bio?: string; // May be absent from the response
}
An optional field (bio?: string) means the key might be missing entirely. This is different from a nullable field, where the key exists but the value might be null.
Handling Edge Cases
Production APIs are messy. Here's how to handle the common gotchas.
Nullable Fields
When a field can be explicitly null:
{
"id": 42,
"username": "jdoe",
"avatarUrl": null,
"deletedAt": null
}
interface User {
id: number;
username: string;
avatarUrl: string | null;
deletedAt: string | null; // ISO date string or null
}
If a field can be both absent and null, combine the two:
interface User {
id: number;
username: string;
middleName?: string | null; // May be missing, or present but null
}
Union Types
Some fields accept multiple types. A search API might return mixed results:
{
"results": [
{ "type": "user", "id": 1, "username": "jdoe" },
{ "type": "post", "id": 2, "title": "Hello" }
]
}
Model this with a discriminated union:
interface UserResult {
type: 'user';
id: number;
username: string;
}
interface PostResult {
type: 'post';
id: number;
title: string;
}
type SearchResult = UserResult | PostResult;
interface SearchResponse {
results: SearchResult[];
}
The type field acts as a discriminant. TypeScript narrows the type automatically when you check it:
function renderResult(result: SearchResult) {
if (result.type === 'user') {
console.log(result.username); // TypeScript knows this is UserResult
} else {
console.log(result.title); // TypeScript knows this is PostResult
}
}
Enums from String Values
When an API returns a fixed set of string values, use string literal unions instead of TypeScript enums:
{ "status": "active" }
// Possible values: "active", "inactive", "suspended"
type UserStatus = 'active' | 'inactive' | 'suspended';
interface User {
id: number;
status: UserStatus;
}
String literal unions are simpler, tree-shakeable, and don't generate runtime JavaScript like enum does. Reserve enum for cases where you need reverse mapping (number to name).
Interfaces vs Type Aliases
Both interface and type can describe object shapes. Here's when to pick each:
Use interface for object shapes you might extend:
interface BaseEntity {
id: number;
createdAt: string;
updatedAt: string;
}
interface User extends BaseEntity {
username: string;
email: string;
}
Use type for unions, intersections, and computed types:
type ApiResponse<T> = {
data: T;
meta: { total: number; page: number };
};
type UserOrAdmin = User | Admin;
In practice, either works for most cases. Pick one convention for your project and stick with it. Many teams use interface for data models and type for everything else.
Using Automated Tools
Manually typing interfaces works for small APIs, but becomes tedious when you're dealing with endpoints that return 50+ fields or deeply nested structures.
Before converting JSON to types, make sure your JSON is valid and well-formatted. Our JSON formatter cleans up messy API responses, and the JSON validator catches syntax errors before they waste your time.
Online Converters
Tools like quicktype, json2ts, and JSON-to-TypeScript converters let you paste a JSON payload and get interfaces back instantly. They handle nested objects, arrays, and optional fields automatically.
The typical workflow:
- Hit your API and copy the response
- Paste the JSON into the converter
- Review the generated types and adjust naming
- Copy the TypeScript into your codebase
Code Generation from OpenAPI Specs
If your API has an OpenAPI (Swagger) specification, skip JSON conversion entirely. Tools like openapi-typescript generate types directly from the spec:
npx openapi-typescript https://api.example.com/openapi.json -o ./src/types/api.ts
This approach is superior because the spec defines optional fields, nullable types, and enums explicitly. You get accurate types without guessing from sample data.
Runtime Validation with Zod
For maximum safety, define your shapes with Zod and infer TypeScript types from them:
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
username: z.string(),
email: z.string().email(),
isActive: z.boolean(),
bio: z.string().nullable().optional(),
});
type User = z.infer<typeof UserSchema>;
// Now validate at runtime
const response = await fetch('/api/users/42');
const data = await response.json();
const user = UserSchema.parse(data); // Throws if shape doesn't match
With Zod, your types and validation logic live in one place. If the API changes shape, parse() throws immediately instead of letting bad data propagate through your app.
Integrating Types with API Calls
Types are only useful if you apply them at the boundary where data enters your application.
Typed Fetch Wrapper
Create a generic fetch function that enforces types:
async function fetchJson<T>(url: string, schema?: z.ZodType<T>): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (schema) {
return schema.parse(data);
}
return data as T;
}
// Usage
const user = await fetchJson<User>('/api/users/42');
const validatedUser = await fetchJson('/api/users/42', UserSchema);
Axios with Types
If you use Axios, apply types to the response generic:
import axios from 'axios';
interface PaginatedResponse<T> {
data: T[];
meta: {
total: number;
page: number;
perPage: number;
lastPage: number;
};
}
async function getUsers(page: number = 1) {
const response = await axios.get<PaginatedResponse<User>>('/api/users', {
params: { page },
});
return response.data; // Fully typed
}
React Query / TanStack Query
In React apps, pair types with your data-fetching library:
import { useQuery } from '@tanstack/react-query';
function useUser(id: number) {
return useQuery({
queryKey: ['user', id],
queryFn: () => fetchJson<User>(`/api/users/${id}`),
});
}
// In your component
const { data: user, isLoading } = useUser(42);
// user is typed as User | undefined
Best Practices
Enable Strict Null Checks
In your tsconfig.json, enable strict mode (or at minimum strictNullChecks). This forces you to handle null and undefined explicitly:
{
"compilerOptions": {
"strict": true
}
}
Without strict null checks, TypeScript lets you access .username on a potentially null value without complaint. That defeats the purpose of typing your API responses.
Use readonly for Immutable Data
API responses are data you received, not data you should mutate. Mark properties as readonly:
interface User {
readonly id: number;
readonly username: string;
readonly email: string;
readonly createdAt: string;
}
Or use a utility type for the entire interface:
type ImmutableUser = Readonly<User>;
This prevents accidental mutations that cause subtle bugs.
Branded Types for IDs
Prevent mixing up IDs of different entities with branded types:
type UserId = number & { readonly __brand: 'UserId' };
type PostId = number & { readonly __brand: 'PostId' };
function getUser(id: UserId): Promise<User> { /* ... */ }
function getPost(id: PostId): Promise<Post> { /* ... */ }
const userId = 42 as UserId;
const postId = 7 as PostId;
getUser(userId); // OK
getUser(postId); // Type error! Can't pass PostId where UserId is expected
Centralize Your Types
Keep all API-related types in a dedicated directory:
src/
types/
api/
user.ts
post.ts
common.ts // Shared types like PaginatedResponse
index.ts // Re-exports
This makes types easy to find, import, and maintain. When an API changes, you update one file and the compiler shows you everything that breaks.
Validate at the Boundary
Never trust external data. Even with perfect types, the API might return something unexpected. Validate responses where they enter your app:
// Don't do this
const user = (await response.json()) as User; // Trusts blindly
// Do this
const data = await response.json();
const user = UserSchema.parse(data); // Validates shape
Type assertions (as User) silence the compiler but don't check the data. Runtime validation (Zod, io-ts, Valibot) actually verifies the shape matches.
Working with Complex JSON Structures
When dealing with large or complex JSON payloads, use the JSON editor to explore and understand the structure before writing types. It's much easier to see nested relationships in a proper editor than in raw text.
For teams working with JSON APIs, check out these related guides:
- JSON Formatting Best Practices — keep your JSON clean and readable
- JSON Schema Validation Guide — validate API contracts with JSON Schema
- JSON API Design Patterns — design APIs that are easy to type
- How to Validate JSON — catch malformed payloads early
Putting It All Together
Here's a complete example that ties everything together—from raw JSON to validated, typed data in a React component:
import { z } from 'zod';
// 1. Define the schema (single source of truth)
const UserSchema = z.object({
id: z.number(),
username: z.string(),
email: z.string().email(),
isActive: z.boolean(),
role: z.enum(['admin', 'editor', 'viewer']),
profile: z.object({
bio: z.string().nullable(),
avatarUrl: z.string().url().nullable(),
socialLinks: z.array(z.object({
platform: z.string(),
url: z.string().url(),
})).optional(),
}),
});
// 2. Infer the type from the schema
type User = z.infer<typeof UserSchema>;
// 3. Create a typed fetch function
async function getUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error(`Failed to fetch user ${id}`);
const data = await response.json();
return UserSchema.parse(data);
}
// 4. Use in your application with full type safety
const user = await getUser(42);
console.log(user.profile.bio); // string | null — compiler knows
console.log(user.profile.socialLinks); // array or undefined — compiler knows
No any. No guessing. No runtime surprises.
Conclusion
Converting JSON to TypeScript types isn't just about making the compiler happy. It's about building a safety net that catches mistakes early, powers your editor's autocomplete, and makes your codebase easier to maintain.
Start simple: copy a JSON response, write the interface manually, and type your fetch calls. As your project grows, adopt Zod for runtime validation and OpenAPI codegen for large APIs. The upfront investment pays for itself the first time a type error catches a bug that would have reached production.