Safe JSON Parsing in TypeScript: unknown Type, Type Guards, and Zod
Last updated:
TypeScript's type system stops at runtime boundaries. JSON.parse() returns any — TypeScript trusts whatever you claim the value is. This guide covers the patterns that make JSON parsing genuinely type-safe: from the unknown type to custom type guards to Zod runtime validation.
The Problem: JSON.parse Returns any
// Dangerous: TypeScript trusts you but won't verify at runtime
const data = JSON.parse('{"name":"Ada"}') as User
// This compiles but crashes at runtime if shape is wrong:
console.log(data.email.toUpperCase()) // TypeError: Cannot read properties of undefined
// TypeScript 4.1+ with strict: true makes this slightly safer with
// return type widening, but JSON.parse() is still anyApproach 1: unknown + Type Guard
Assign the result to unknown, then narrow it with a type guard function before accessing properties.
interface User {
id: string
email: string
name: string
age?: number
}
function isUser(value: unknown): value is User {
if (typeof value !== 'object' || value === null) return false
const obj = value as Record<string, unknown>
return (
typeof obj.id === 'string' &&
typeof obj.email === 'string' &&
typeof obj.name === 'string' &&
(obj.age === undefined || typeof obj.age === 'number')
)
}
// Safe parsing
function parseUser(json: string): User | null {
try {
const parsed: unknown = JSON.parse(json)
return isUser(parsed) ? parsed : null
} catch {
return null // malformed JSON
}
}
const user = parseUser('{"id":"1","email":"ada@example.com","name":"Ada"}')
if (user) {
console.log(user.email.toUpperCase()) // TypeScript knows this is string
}The value is User return type is the type predicate — TypeScript uses it to narrow the type inside an if (isUser(x)) block. The function must correctly verify all required fields; TypeScript cannot check whether the guard is accurate.
Approach 2: Zod (Recommended)
Zod generates TypeScript types from schemas and validates data at runtime. One source of truth for both the type and the validation logic.
import { z } from 'zod'
const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
name: z.string().min(1),
age: z.number().int().positive().optional(),
})
// Infer TypeScript type from schema — no duplicate definition
type User = z.infer<typeof UserSchema>
// parse() throws ZodError on invalid data
function parseUser(json: string): User {
const parsed: unknown = JSON.parse(json) // may throw SyntaxError
return UserSchema.parse(parsed) // may throw ZodError
}
// safeParse() never throws — returns { success, data } | { success, error }
function tryParseUser(json: string):
| { success: true; data: User }
| { success: false; error: string } {
try {
const parsed: unknown = JSON.parse(json)
const result = UserSchema.safeParse(parsed)
if (result.success) {
return { success: true, data: result.data }
}
return { success: false, error: result.error.message }
} catch (e) {
return { success: false, error: 'Invalid JSON' }
}
}Zod Error Details
const result = UserSchema.safeParse({ id: 1, email: 'not-an-email' })
if (!result.success) {
// flatten() gives { fieldErrors: {...}, formErrors: [...] }
console.log(result.error.flatten())
// {
// fieldErrors: {
// id: ['Expected string, received number'],
// email: ['Invalid email'],
// name: ['Required']
// },
// formErrors: []
// }
}Wrapping JSON.parse Errors
// TypeScript 4.0+: caught errors are unknown by default (with strict config)
// useUnknownInCatchVariables: true in tsconfig.json
function safeParse(text: string): unknown {
try {
return JSON.parse(text)
} catch (e) {
// e is unknown — must narrow before accessing .message
const message = e instanceof Error ? e.message : String(e)
throw new Error(`JSON parse failed: ${message}`)
}
}
// Or return null on failure
function tryParse(text: string): unknown | null {
try { return JSON.parse(text) }
catch { return null }
}
// Or return a Result type
type ParseResult<T> =
| { ok: true; value: T }
| { ok: false; error: Error }
function parseResult<T>(
schema: z.ZodSchema<T>,
text: string
): ParseResult<T> {
try {
const value = schema.parse(JSON.parse(text))
return { ok: true, value }
} catch (e) {
return { ok: false, error: e instanceof Error ? e : new Error(String(e)) }
}
}Safe Fetch + JSON Validation
import { z } from 'zod'
const PostSchema = z.object({
id: z.number(),
title: z.string(),
body: z.string(),
userId: z.number(),
})
const PostListSchema = z.array(PostSchema)
async function fetchPosts(): Promise<z.infer<typeof PostListSchema>> {
const res = await fetch('https://jsonplaceholder.typicode.com/posts')
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
const raw: unknown = await res.json()
return PostListSchema.parse(raw) // throws if API shape changed
}
// Non-throwing version
async function tryFetchPosts() {
const res = await fetch('/api/posts')
if (!res.ok) return null
const raw: unknown = await res.json()
const result = PostListSchema.safeParse(raw)
return result.success ? result.data : null
}TypeScript tsconfig Best Practices
// tsconfig.json — enable all strict checks
{
"compilerOptions": {
"strict": true, // enables all strict family flags
"useUnknownInCatchVariables": true, // catch(e) → e: unknown
"noImplicitAny": true, // disallow implicit any
"strictNullChecks": true, // null/undefined must be handled
"exactOptionalPropertyTypes": true // optional ≠ undefined-assignable
}
}
// With strict: true, TypeScript catches more unsafe patterns at compile time
// but JSON.parse() still returns any — runtime validation is always neededComparison: Type Guard vs Zod vs ArkType
| Approach | Bundle | Error detail | Type inference | Best for |
|---|---|---|---|---|
| Type guard | 0 kb | Manual | Manual duplicate | Simple shapes, zero deps |
| Zod | ~13 kb gz | Rich + flatten() | z.infer<> | Most TypeScript projects |
| Valibot | ~2 kb gz | Rich | InferOutput<> | Bundle-size-sensitive apps |
| ArkType | ~10 kb gz | Rich | Compile-time checked | Compile-time schema errors |
| TypeBox | ~8 kb gz | JSON Schema errors | Static<> | Fastify, OpenAPI integration |
FAQ
Why does JSON.parse return any in TypeScript?
TypeScript can't know at compile time what a runtime string will contain, so JSON.parse() returns any. The any type disables all type checks on the result — you can access any property without error even if it doesn't exist. This is a known limitation. The solution: immediately treat the result as unknown (by assigning to a typed variable or using a schema library) and validate before use.
What is the safest way to parse JSON in TypeScript?
The gold standard: wrap JSON.parse in try/catch and immediately validate the result with Zod's safeParse. This protects against two failure modes: (1) malformed JSON that throws SyntaxError, and (2) structurally valid JSON that doesn't match your expected type. Using safeParse instead of parse keeps both failures in normal control flow without exceptions — ideal for API handlers where you want to return a 422 error rather than letting an unhandled exception reach a 500 handler.
How do I write a type guard for a JSON object in TypeScript?
A type guard function has signature function isUser(v: unknown): v is User. Inside, check each required property: typeof v === 'object' && v !== null first, then check each property type. The value is User return type tells TypeScript the narrowing that occurs when the function returns true. Type guards are zero-dependency and fast, but brittle — adding a field to the interface doesn't automatically update the guard. For complex types with many fields or nested objects, Zod is much more maintainable.
How do I handle JSON.parse errors in TypeScript?
Wrap in try/catch. With strict: true (or useUnknownInCatchVariables: true), the caught value is typed unknown — narrow it with e instanceof Error before accessing e.message. Common pattern: return null on failure for simple cases, or return a discriminated union { ok: true; value: T } | { ok: false; error: Error } for callers that need to distinguish between "invalid JSON" and "unexpected structure".
What is the difference between unknown and any for JSON?
any lets you do anything without checks — it silently escapes TypeScript's type system. unknown forces you to narrow the type before using it. Practically: assigning JSON.parse(text) as User is an unsafe cast that compiles but could crash. Assigning to unknown then validating with Zod or a type guard means TypeScript won't let you access properties until you've proven the shape. Enable strict: true in tsconfig to maximize TypeScript's ability to catch unsafe patterns.
How does Zod safeParse differ from parse for JSON validation?
schema.parse(data) throws a ZodError if validation fails — useful when you want failure to propagate as an exception. schema.safeParse(data) returns { success: true, data: T } or { success: false, error: ZodError } — useful in API handlers where you want to return structured error responses. Use result.error.flatten() to get per-field errors suitable for returning to clients. The choice between them is mostly about control flow preference — both validate identically.
How do I parse JSON from a fetch response safely in TypeScript?
response.json() returns Promise<any> — same problem as JSON.parse. Await it into an unknown-typed variable, then validate with Zod: const raw: unknown = await res.json(); const data = MySchema.parse(raw);. This catches API contract changes immediately rather than letting wrong-shaped data propagate into your application logic. For production, combine with HTTP error checking: check res.ok before calling res.json().
What libraries besides Zod can I use for safe JSON parsing in TypeScript?
Valibot is tree-shakeable and ~2 kb gzipped — ideal when bundle size matters. ArkType catches schema definition errors at TypeScript compile time (not just runtime). TypeBox generates JSON Schema simultaneously with TypeScript types — native fit for Fastify and OpenAPI. For NestJS, class-validator with class-transformer is common. For zero-dependency environments, custom type guards work for simple shapes. Zod remains the most popular choice due to its rich ecosystem, IDE autocomplete, and transform/pipe capabilities.
Further reading and primary sources
- Zod documentation — Complete Zod API: schemas, transformations, refinements, and error formatting
- TypeScript Handbook: Narrowing — typeof, instanceof, in operator, type predicates, and discriminated unions
- Parse JSON in TypeScript (Jsonic) — TypeScript JSON parsing patterns: generics, interfaces, and type safety
- Zod Schema Validation (Jsonic) — Full Zod guide: object schemas, safeParse, transforms, and Zod ↔ JSON Schema conversion
- JSON Parse Error JavaScript (Jsonic) — Diagnosing and fixing SyntaxError: Unexpected token errors from JSON.parse