Skip to main content

TypeScript JSON Type

You call an API endpoint, parse the response with JSON.parse(), and everything works fine in development. Then production hits and your app crashes because the API returned accountBalance: "1500" as a string instead of a number. TypeScript didn't catch it, and now you're hunting down a runtime error that could have been prevented.

This is the core challenge with JSON in TypeScript: JSON.parse() returns any, which means you lose all type safety at the exact moment you need it most. In this guide, we'll show you how to parse JSON safely, validate data at runtime, and build type guards that catch these issues before they reach production

Defining a JSON Type in TypeScript

To define a JSON type in TypeScript, create an interface or type alias that describes the structure of your JSON data. Here's how to do it:

interface User {
id: number;
name: string;
email: string;
}

You can also use type aliases when you need more flexibility:

type APIResponse = {
status: 'success' | 'error';
data: unknown;
timestamp: string;
};

When defining JSON types, consider using interfaces for object shapes and types for more complex type definitions. These TypeScript features ensure your JSON data adheres to a specific structure.

Parsing JSON Data in TypeScript

The problem with JSON.parse() is that it returns any, which defeats TypeScript's entire purpose. You need to validate the parsed data at runtime to ensure it matches your expected type.

Here's the unsafe way most developers start:

// Don't do this
function parseUserData(jsonString: string): User {
const user = JSON.parse(jsonString) as User; // Type assertion without validation
return user; // TypeScript thinks this is safe, but it's not
}

This compiles fine, but if the API returns malformed data, your app crashes. Here's a safer approach using a type guard:

function isUser(data: unknown): data is User {
return (
typeof data === 'object' &&
data !== null &&
typeof (data as any).id === 'number' &&
typeof (data as any).name === 'string' &&
typeof (data as any).email === 'string'
);
}

function parseUserData(jsonString: string): User | null {
try {
const parsed: unknown = JSON.parse(jsonString);
return isUser(parsed) ? parsed : null;
} catch {
return null;
}
}

Notice we're using unknown instead of any for the parsed result. This forces us to validate the data before using it. The type guard isUser checks the actual shape of the data at runtime, not just what TypeScript thinks it should be.

For more robust validation, consider using libraries like Zod or io-ts, which provide runtime validation with TypeScript integration. When building Convex applications, you can leverage the built-in argument validation system for similar type safety benefits.

Handling JSON with TypeScript's Type System

TypeScript's type system provides several ways to handle JSON data safely. You can use index signatures for dynamic object shapes:

interface Config {
[key: string]: any;
}

const config: Config = {
db: {
host: 'localhost',
port: 5432,
},
api: {
endpoint: 'https://api.example.com',
},
};

For more specific type definitions, consider using TypeScript utility types like Record<K, T>:

type APIResponse = {
status: 'success' | 'error';
data: Record<string, unknown>;
timestamp: string;
};

When working with nested JSON structures, you can combine interfaces with type assertions:

interface Database {
host: string;
port: number;
credentials?: {
username: string;
password: string;
};
}

function parseConfig(jsonString: string): Database | null {
try {
const parsed = JSON.parse(jsonString);
if (isDatabase(parsed)) {
return parsed;
}
return null;
} catch {
return null;
}
}

The TypeScript Record<K, T> type is useful for defining JSON objects with specific key-value pairs. When building Convex applications, you can follow similar patterns in your database schema definitions.

Validating JSON Data Structure in TypeScript

Runtime validation is where you catch the bugs that TypeScript's compile-time checks miss. You've got two main options: write your own type guards or use a validation library.

Manual Validation with Type Guards

For simple cases, a custom type guard works well:

function validateUserData(data: unknown): data is User {
if (typeof data !== 'object' || data === null) {
return false;
}

const obj = data as Record<string, unknown>;

return (
typeof obj.id === 'number' &&
typeof obj.name === 'string' &&
typeof obj.email === 'string' &&
obj.email.includes('@') // Basic email validation
);
}

// Usage
const jsonString = '{"id": 1, "name": "Sarah", "email": "sarah@example.com"}';
const parsed: unknown = JSON.parse(jsonString);

if (validateUserData(parsed)) {
// TypeScript now knows parsed is a User
console.log(parsed.name); // Type-safe access
}

For nested structures, validation gets more complex but follows the same pattern:

interface Product {
id: number;
name: string;
details: {
price: number;
inStock: boolean;
};
}

function validateProduct(data: unknown): data is Product {
if (typeof data !== 'object' || data === null) return false;

const obj = data as Record<string, unknown>;

return (
typeof obj.id === 'number' &&
typeof obj.name === 'string' &&
typeof obj.details === 'object' &&
obj.details !== null &&
typeof (obj.details as any).price === 'number' &&
typeof (obj.details as any).inStock === 'boolean'
);
}

Using Zod for Validation

For anything beyond simple objects, a validation library saves you time and reduces bugs. Here's the same validation with Zod:

import { z } from 'zod';

const userSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(), // Built-in email validation
});

// Parse and validate in one step
const result = userSchema.safeParse(JSON.parse(jsonString));

if (result.success) {
console.log(result.data); // Typed as User
} else {
console.error('Validation failed:', result.error.issues);
}

Zod gives you detailed error messages and handles edge cases you might forget. The safeParse method returns either { success: true, data: T } or { success: false, error: ZodError }, making error handling straightforward.

Choosing a Validation Approach

ApproachBest ForProsCons
Manual type guardsSimple objects, learningNo dependencies, full controlVerbose for complex types, easy to miss edge cases
ZodMost projectsGreat DX, detailed errors, small bundleLearning curve, adds dependency
AjvJSON Schema complianceIndustry standard, fastMore boilerplate, less TypeScript-friendly
io-tsFunctional programming fansComposable, fp-ts integrationSteeper learning curve

In Convex applications, you can use the built-in validator system which provides similar runtime validation capabilities. For TypeScript types that need validation, consider using comprehensive validation libraries or building custom validators like the examples above.

Converting JSON to a TypeScript Interface

To convert JSON to a TypeScript interface, define an interface that matches the JSON data structure.

interface User {
id: number;
name: string;
email: string;
preferences?: {
theme: string;
notifications: boolean;
};
}

// Basic conversion
function convertToUser(jsonString: string): User | null {
try {
const jsonData = JSON.parse(jsonString);

// Type assertion with validation
const user: User = {
id: jsonData.id,
name: jsonData.name,
email: jsonData.email,
preferences: jsonData.preferences
};

return user;
} catch {
return null;
}
}

For more complex conversions, use a mapping function:

function mapJsonToUser(jsonData: any): User {
return {
id: Number(jsonData.id),
name: String(jsonData.name),
email: String(jsonData.email),
preferences: jsonData.preferences ? {
theme: String(jsonData.preferences.theme),
notifications: Boolean(jsonData.preferences.notifications)
} : undefined
};
}

// Using with arrays
function parseUserArray(jsonString: string): User[] {
try {
const jsonArray = JSON.parse(jsonString);
return jsonArray.map(mapJsonToUser);
} catch {
return [];
}
}

When working with nested objects or TypeScript interfaces, consider creating separate interfaces for each nested structure:

interface Address {
street: string;
city: string;
country: string;
}

interface Employee {
id: number;
name: string;
address: Address;
department: string;
}

function convertToEmployee(json: any): Employee {
return {
id: json.id,
name: json.name,
address: {
street: json.address.street,
city: json.address.city,
country: json.address.country
},
department: json.department
};
}

In Convex applications, you can leverage the database schema to automatically generate TypeScript interfaces, as described in the TypeScript documentation.

Ensuring Type Safety with JSON in TypeScript

Type safety with JSON isn't just about defining interfaces. You need runtime validation, proper error handling, and a consistent pattern across your codebase. Here's how to build a robust system.

Creating Reusable Type Guards

Start with type guards that give clear feedback when validation fails:

interface User {
id: number;
name: string;
email: string;
}

function isUser(data: unknown): data is User {
if (typeof data !== 'object' || data === null) return false;

const obj = data as Record<string, unknown>;

return (
typeof obj.id === 'number' &&
typeof obj.name === 'string' &&
typeof obj.email === 'string' &&
obj.email.includes('@')
);
}

function parseUser(jsonString: string): User | null {
try {
const parsed: unknown = JSON.parse(jsonString);
if (isUser(parsed)) {
return parsed;
}
console.error('Data does not match User interface');
return null;
} catch (error) {
console.error('Invalid JSON:', error);
return null;
}
}

Composing Type Guards for Complex Data

When you've got nested objects, compose type guards instead of writing one massive validation function:

interface Order {
id: number;
customer: User;
items: Array<{
productId: number;
quantity: number;
}>;
}

function isOrderItem(data: unknown): data is { productId: number; quantity: number } {
if (typeof data !== 'object' || data === null) return false;

const obj = data as Record<string, unknown>;
return typeof obj.productId === 'number' && typeof obj.quantity === 'number';
}

function isOrder(data: unknown): data is Order {
if (typeof data !== 'object' || data === null) return false;

const obj = data as Record<string, unknown>;

return (
typeof obj.id === 'number' &&
isUser(obj.customer) &&
Array.isArray(obj.items) &&
obj.items.every(isOrderItem)
);
}

This approach is easier to test and reuse across your codebase.

Generic Parsing Function

Create a generic parser that works with any validator:

function parseJson<T>(
jsonString: string,
validator: (data: unknown) => data is T
): T | null {
try {
const parsed: unknown = JSON.parse(jsonString);
return validator(parsed) ? parsed : null;
} catch {
return null;
}
}

// Usage with different types
const user = parseJson(userJsonString, isUser);
const order = parseJson(orderJsonString, isOrder);

This pattern keeps your parsing logic consistent and your code DRY.

For Convex applications, combine these patterns with the Convex validator system to ensure type safety across your entire application. The TypeScript Map type feature can be useful for transforming JSON objects while maintaining type safety.

Working with JSON Arrays in TypeScript

Handling JSON arrays in TypeScript involves defining the type of the array elements.

interface User {
id: number;
name: string;
email: string;
}

// Basic array typing
const users: User[] = [
{ id: 1, name: 'John Doe', email: 'john@example.com' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' },
];

// Parsing JSON arrays with validation
function parseUserArray(jsonString: string): User[] | null {
try {
const parsed = JSON.parse(jsonString);

if (!Array.isArray(parsed)) {
return null;
}

// Validate each element
if (parsed.every(isUser)) {
return parsed as User[];
}

return null;
} catch {
return null;
}
}

Working with array methods while maintaining type safety:

// Type-safe array operations
function processUsers(users: User[]) {
// Map with type safety
const userNames = users.map(user => user.name);

// Filter with type guard
const activeUsers = users.filter((user): user is User => {
return user.id > 0;
});

// Reduce with typed accumulator
const emailMap = users.reduce<Record<string, string>>((acc, user) => {
acc[user.email] = user.name;
return acc;
}, {});
}

Handling nested arrays and complex structures:

interface Order {
id: number;
items: {
productId: number;
quantity: number;
}[];
}

function validateOrderArray(data: unknown): data is Order[] {
if (!Array.isArray(data)) return false;

return data.every(order =>
typeof order === 'object' &&
typeof order.id === 'number' &&
Array.isArray(order.items) &&
order.items.every(item =>
typeof item.productId === 'number' &&
typeof item.quantity === 'number'
)
);
}

When working with TypeScript array operations in Convex, you can use similar patterns for handling database queries that return arrays. For more complex array operations, consider using an array of objects to maintain type safety.

Converting TypeScript Objects to JSON with JSON.stringify()

While JSON.parse() gets most of the attention, JSON.stringify() has its own quirks you need to understand. Not everything serializes the way you'd expect.

Basic Serialization

The straightforward case works as you'd expect:

interface ApiRequest {
userId: number;
action: string;
timestamp: string;
}

const request: ApiRequest = {
userId: 123,
action: 'update_profile',
timestamp: new Date().toISOString(),
};

const jsonString = JSON.stringify(request);
// '{"userId":123,"action":"update_profile","timestamp":"2025-12-08T10:30:00.000Z"}'

What Gets Stripped During Serialization

Here's where things get tricky. JSON.stringify() silently drops certain values:

interface UserProfile {
name: string;
age: number;
lastLogin?: Date;
metadata?: Record<string, any>;
}

const profile: UserProfile = {
name: 'Alex',
age: 28,
lastLogin: new Date(), // Becomes a string
metadata: {
theme: 'dark',
notifications: undefined, // Gets dropped
saveData: () => {}, // Functions get dropped too
},
};

console.log(JSON.stringify(profile));
// {"name":"Alex","age":28,"lastLogin":"2025-12-08T10:30:00.000Z","metadata":{"theme":"dark"}}

Notice what disappeared: the undefined value and the function. This can cause subtle bugs if you're not careful.

Handling Dates Properly

Dates are a common pain point. They serialize to strings, but don't parse back to Date objects automatically:

interface Event {
title: string;
startDate: Date;
}

const event: Event = {
title: 'Team Meeting',
startDate: new Date('2025-12-08'),
};

// Serialize
const json = JSON.stringify(event);

// Parse back
const parsed = JSON.parse(json);
console.log(typeof parsed.startDate); // "string", not Date!

// You need a custom reviver
const restored = JSON.parse(json, (key, value) => {
if (key === 'startDate') {
return new Date(value);
}
return value;
});
console.log(restored.startDate instanceof Date); // true

Using toJSON() for Custom Serialization

You can control serialization behavior by adding a toJSON() method:

class ApiResponse {
constructor(
public data: any,
public timestamp: Date,
private internalToken: string // Don't want this serialized
) {}

toJSON() {
return {
data: this.data,
timestamp: this.timestamp.toISOString(),
// internalToken intentionally omitted
};
}
}

const response = new ApiResponse({ userId: 1 }, new Date(), 'secret-token');
console.log(JSON.stringify(response));
// {"data":{"userId":1},"timestamp":"2025-12-08T10:30:00.000Z"}

This gives you fine-grained control over what gets serialized and how.

Common JSON Parsing Errors and How to Fix Them

When working with JSON in production, you'll hit these errors repeatedly. Here's how to handle them.

SyntaxError: Unexpected Token

This is the most common JSON parsing error, and it happens when the JSON string is malformed:

// Missing quotes around keys
const badJson1 = '{name: "John"}';

// Trailing comma
const badJson2 = '{"name": "John",}';

// Single quotes instead of double
const badJson3 = "{'name': 'John'}";

// All of these throw: SyntaxError: Unexpected token

Fix it with validation before parsing:

function safeJsonParse<T>(jsonString: string): { success: true; data: T } | { success: false; error: string } {
try {
const data = JSON.parse(jsonString) as T;
return { success: true, data };
} catch (error) {
if (error instanceof SyntaxError) {
return { success: false, error: `Invalid JSON syntax: ${error.message}` };
}
return { success: false, error: 'Unknown parsing error' };
}
}

// Usage
const result = safeJsonParse<User>(apiResponse);
if (result.success) {
console.log(result.data);
} else {
console.error(result.error);
}

TypeError: Cannot Read Property of Undefined

This happens when you try to access nested properties without checking if they exist:

const json = '{"user": {"name": "Sarah"}}';
const data = JSON.parse(json);

// Crashes if user or address don't exist
// console.log(data.user.address.city);

// Use optional chaining instead
console.log(data.user?.address?.city); // undefined, no crash

Learn more about this in the TypeScript optional chaining guide.

Handling Unexpected Null or Undefined

APIs sometimes return null or undefined where you expect an object:

interface ApiResponse {
user: User | null;
}

function processResponse(jsonString: string): User | null {
try {
const parsed: unknown = JSON.parse(jsonString);

// Check if parsed is an object first
if (typeof parsed !== 'object' || parsed === null) {
return null;
}

const response = parsed as ApiResponse;

// Validate the user field exists and is valid
if (response.user && isUser(response.user)) {
return response.user;
}

return null;
} catch {
return null;
}
}

For more error handling patterns, check out the TypeScript try-catch guide.

Type Mismatches After Parsing

The API documentation says age is a number, but it comes back as a string. Handle this with validation:

function parseUserWithCoercion(jsonString: string): User | null {
try {
const parsed: unknown = JSON.parse(jsonString);

if (typeof parsed !== 'object' || parsed === null) {
return null;
}

const obj = parsed as Record<string, unknown>;

// Coerce types when needed
return {
id: typeof obj.id === 'string' ? parseInt(obj.id, 10) : (obj.id as number),
name: String(obj.name),
email: String(obj.email),
};
} catch {
return null;
}
}

Using the TypeScript typeof operator helps you check and coerce types safely.

Final Thoughts on TypeScript JSON Type

The gap between TypeScript's compile-time type system and JSON's runtime nature is where most bugs hide. Here's what you need to remember:

Always validate parsed JSON. Type assertions like as User are lies you tell TypeScript. Use type guards that check the actual data structure at runtime.

Use unknown instead of any. When you parse JSON, declare it as unknown to force yourself to validate before using it. This one change prevents entire classes of bugs.

Build reusable validators. Whether you write type guards manually or use Zod, create a consistent validation layer. Don't scatter JSON.parse() calls throughout your codebase without validation.

Watch for serialization gotchas. JSON.stringify() drops undefined values and functions. Dates become strings. If you need custom behavior, use the toJSON() method.

Handle errors explicitly. Wrap JSON.parse() in try-catch blocks and return meaningful error types instead of letting exceptions bubble up.

Start with manual type guards for simple objects. Move to Zod or a similar library once you're dealing with complex nested structures or need detailed validation errors. Either way, never trust data from external sources without checking it first.