TypeScript:你以为安全的 `JSON.parse` 其实是颗雷 — 运行时类型安全实战

28 阅读3分钟

问题场景

你在写一个用户信息获取接口:

interface User {
  id: number
  name: string
  email: string
  avatar: string
  age: number
}

// API 返回数据
const raw = localStorage.getItem('user_profile')
const user: User = JSON.parse(raw!)  // ❌ TS 无报错,但运行时崩了
console.log(user.name.toUpperCase())  // 💥 Cannot read properties of null

TS 类型检查通过,代码跑起来就炸。为什么?

因为 JSON.parse 的返回类型是 anyany 可以赋值给任何类型,TS 编译器认为这是合法的。但你解析出来的是啥?可能是 nullundefined{}、甚至数组——全看后端 / 本地存储给什么。

这就是 TS 最大的幻觉:类型标注 ≠ 类型保障。as 断言只是告诉编译器"相信我",但运行时没有任何校验。

原因分析

阶段做了什么是否安全
编译时TS 看到 JSON.parse(raw!) 返回 any,赋值给 User OK
运行时JSON.parse 真的解析了一段 JSON❌ 不校验结构
运行时访问 user.name.toUpperCase()💥 user 可能是 null

核心矛盾:TS 只在编译期生效,JSON.parse 发生在运行期。两者之间没有任何桥梁。

常见的翻车场景:

  • localStorage 被用户手动篡改了
  • 本地缓存格式版本不匹配(旧版本存的是 {name: string},新版本要 {firstName: string}
  • API 字段变了但前端忘了同步类型
  • 某个字段返回了 null 但 TS 类型写了 string

解决方案

方案一:手写类型守卫(最朴素)

function isUser(data: unknown): data is User {
  if (!data || typeof data !== 'object') return false
  const d = data as Record<string, unknown>
  return (
    typeof d.id === 'number' &&
    typeof d.name === 'string' &&
    typeof d.email === 'string' &&
    typeof d.avatar === 'string' &&
    typeof d.age === 'number'
  )
}

const raw = localStorage.getItem('user_profile')
const parsed: unknown = JSON.parse(raw ?? 'null')

if (!isUser(parsed)) {
  throw new Error('用户数据格式异常,请重新登录')
}

// 此时 parsed 已被收窄为 User
console.log(parsed.name.toUpperCase()) // ✅ 安全

优点:零依赖,逻辑清晰 缺点:字段多了写死人,容易漏字段

方案二:Zod 运行时校验(推荐生产使用)

Zod 是当前最流行的运行时校验库,让你写一次 Schema 同时得到 TS 类型 + 运行时校验

import { z } from 'zod'

// 定义 Schema — 同时生成 TS 类型
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  avatar: z.string().url().default('/default-avatar.png'),
  age: z.number().min(0).max(150).optional(),
})

// 👇 从 Schema 推导出 TS 类型,保持一致
type User = z.infer<typeof UserSchema>
// 等价于:
// { id: number; name: string; email: string; avatar: string; age?: number }

// 解析数据
const raw = localStorage.getItem('user_profile')
const result = UserSchema.safeParse(JSON.parse(raw ?? 'null'))

if (!result.success) {
  console.error('数据校验失败:', result.error.issues)
  // issues 详细告诉你哪个字段不对
  // [{ code: 'invalid_type', path: ['email'], message: 'Expected string, received null' }]
  return
}

// result.data 已经是完整的 User 类型,且 avatar 有默认值
const user: User = result.data

Zod 的优势:

能力Zod手写守卫
TS 类型推导z.infer 自动推导❌ 手动定义
字段级错误信息❌ 只能整体返回
默认值处理.default()❌ 自己写合并
嵌套对象/数组✅ 原生支持😵 递归写到崩溃
数据转换.transform()

更多 Zod 实用场景:

// 解析 API 响应 —— 后端说好的一定给,实际可能没有
const ApiResponseSchema = z.object({
  code: z.number(),
  data: z.unknown(),
  message: z.string().optional(),
})

// 解析复杂嵌套
const ConfigSchema = z.object({
  plugins: z.array(z.object({
    name: z.string(),
    enabled: z.boolean().default(true),
    options: z.record(z.unknown()).optional(),
  })),
  version: z.string().regex(/^\d+.\d+.\d+$/),
})

// .parse() 直接抛异常 vs .safeParse() 返回 Result 类型
// 前端建议用 safeParse,优雅处理错误

方案三:Valibot —— Zod 的轻量替代

Valibot 是 2023 年底出现的新秀,核心优势:Tree-shakable,按需引入后体积只有 Zod 的 1/10:

import { object, string, number, email, minLength, safeParse } from 'valibot'

const UserSchema = object({
  id: number(),
  name: string([minLength(1)]),
  email: string([email()]),
  age: number(),
})

const result = safeParse(UserSchema, JSON.parse(raw ?? 'null'))

如果你的项目对包体积敏感(比如组件库、SDK),Valibot 更合适。

实操代码:封装通用 JSON 安全解析函数

import { z } from 'zod'

/**
 * 安全解析 JSON + Schema 校验
 * @param jsonStr JSON 字符串(可能为 null/undefined/非法格式)
 * @param schema Zod Schema
 * @param fallback 解析失败时返回的默认值
 */
function safeJsonParse<T extends z.ZodTypeAny>(
  jsonStr: string | null | undefined,
  schema: T,
  fallback?: z.infer<T>
): z.infer<T> {
  if (!jsonStr) {
    if (fallback !== undefined) return fallback
    throw new Error('JSON 字符串为空')
  }

  let parsed: unknown
  try {
    parsed = JSON.parse(jsonStr)
  } catch {
    if (fallback !== undefined) return fallback
    throw new Error('JSON 解析失败')
  }

  const result = schema.safeParse(parsed)
  if (!result.success) {
    console.warn('Schema 校验失败,使用默认值', result.error.issues)
    if (fallback !== undefined) return fallback
    throw new Error(`数据格式异常: ${result.error.issues.map(i => i.path.join('.') + ': ' + i.message).join('; ')}`)
  }

  return result.data
}

// 使用示例
const userSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
})

// 哪怕 localStorage 被清空,也不会崩溃
const user = safeJsonParse(
  localStorage.getItem('user_profile'),
  userSchema,
  { id: 0, name: 'Guest', email: 'guest@example.com' }
)

要点总结

  • JSON.parse 返回 anyany 赋值给任何类型 TS 都放行——这是运行时崩溃的根源
  • as 类型断言 ≠ 数据校验,它只是让编译器闭嘴
  • Zod 的 z.infer 解决了"写两遍类型"的问题,Schema 即类型源
  • 所有"外部输入"都需要运行时校验:localStorage、API 响应、URL 参数、用户输入
  • 非核心场景用 Zod 的 safeParse + 默认值兜底,核心场景(支付、身份认证)必须严格校验
  • 包体积敏感用 Valibot,否则 Zod 生态更成熟(还有 Zod 的 OpenAPI 生成器等插件)

一条铁律:不要相信任何从 JSON.parse 出来的数据。TS 类型只是合同,Zod 才是保安。