Перейти к содержимому
27 / 36

Как типизировать JSON-значение?


Полный ответ

Наивный подход и почему он плохой:

TypeScript
// ❌ any — ноль типобезопасности

function parse(raw: string): any {
  return JSON.parse(raw);
}

// ❌ unknown — безопасно, но неудобно, нужен каст на каждом шаге

function parse2(raw: string): unknown {
  return JSON.parse(raw);
}

Правильный рекурсивный тип:

TypeScript
// Точное описание всех допустимых JSON-значений
type JsonPrimitive = string | number | boolean | null;
type JsonArray = JsonValue[];
type JsonObject = { [key: string]: JsonValue };
type JsonValue = JsonPrimitive | JsonArray | JsonObject;

Использование:

TypeScript
// Типобезопасный парсинг

function parseJson(raw: string): JsonValue {
  return JSON.parse(raw) as JsonValue;
}

const data = parseJson('{"name": "Alice", "scores": [95, 87]}');

// TypeScript знает, что это JsonValue
if (typeof data === "object" && data !== null && !Array.isArray(data)) {
  const name = data["name"]; // JsonValue
  if (typeof name === "string") {
    console.log(name.toUpperCase()); // безопасно
  }
}

Типизация API-ответа:

TypeScript
// Конкретная структура, совместимая с JSON
interface ApiResponse {
  status: "ok" | "error";
  data: JsonValue;
  timestamp: number;
}

// Проверяем, что интерфейс действительно JSON-совместим
type AssertJsonCompatible<T extends JsonValue> = T;
type Check = AssertJsonCompatible<ApiResponse>; // ОК

// А вот это не скомпилируется:
interface BadResponse {
  created: Date;        // Date не сериализуется
  handler: () => void;  // функции нельзя
}
// type Fail = AssertJsonCompatible<BadResponse>; // ошибка компиляции

Типизация JSON с известной схемой:

TypeScript
// Когда знаете структуру — используйте конкретный тип
interface UserConfig {
  theme: "light" | "dark";
  fontSize: number;
  shortcuts: Record<string, string>;
  recent: string[];
}

function loadConfig(raw: string): UserConfig {
  const parsed: JsonValue = JSON.parse(raw);

  // Валидация через Zod, io-ts или ручную проверку
  return parsed as UserConfig; // после валидации
}

Generic-обёртка для JSON-совместимых типов:

TypeScript
// Утилита: извлечь только JSON-совместимые свойства
type JsonCompatible<T> = {
  [K in keyof T]: T[K] extends JsonValue
  ? T[K]
  : T[K] extends object
  ? JsonCompatible<T[K]>
  : never;
};

interface User {
  id: number;
  name: string;
  created: Date;        // не JSON
  greet: () => string;  // не JSON
}

type JsonUser = JsonCompatible<User>;
// { id: number; name: string; created: never; greet: never }

Реальные кейсы

Безопасная работа с localStorage:

TypeScript
function getFromStorage(key: string): JsonValue | undefined {
  const raw = localStorage.getItem(key);
  if (raw === null) return undefined;
  return JSON.parse(raw) as JsonValue;
}

function setToStorage(key: string, value: JsonValue): void {
  localStorage.setItem(key, JSON.stringify(value));
}

setToStorage("prefs", { theme: "dark", fontSize: 14 }); // OK
// setToStorage("fn", () => {}); // ошибка: функция не JsonValue

Ошибка: забыть, что undefined не является JSON-значением:

TypeScript
// ❌ undefined тихо исчезает при сериализации
const obj = { a: 1, b: undefined };
JSON.stringify(obj); // '{"a":1}' — поле b пропало

// ✅ Используйте null для явного «пусто»
const obj2 = { a: 1, b: null };
JSON.stringify(obj2); // '{"a":1,"b":null}'

Резюме

JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue } — рекурсивный тип, покрывающий весь JSON. Он защищает от случайной сериализации Date, undefined, функций и классов. Используйте его для типизации generic API, хранилищ и межсервисного обмена.

Как типизировать JSON-значение? | JScriptiser