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 any

Approach 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 needed

Comparison: Type Guard vs Zod vs ArkType

ApproachBundleError detailType inferenceBest for
Type guard0 kbManualManual duplicateSimple shapes, zero deps
Zod~13 kb gzRich + flatten()z.infer<>Most TypeScript projects
Valibot~2 kb gzRichInferOutput<>Bundle-size-sensitive apps
ArkType~10 kb gzRichCompile-time checkedCompile-time schema errors
TypeBox~8 kb gzJSON Schema errorsStatic<>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