Лучший опыт

Освоение безопасной для типов JSON-сериализации в TypeScript.

Практически каждое веб-приложение нуждается в сериализации данных. Такая потребность возникает в следующих ситуациях: передача данных по сети (например, HTTP-запросы, веб-сокеты); эмбеддинг данных в HTML (например, для гидратации); хранение данных в постоянном хранилище (например, LocalStorage); обмен данными между процессами (например, между веб-воркерами или окнами/вкладками браузера с помощью postMessage). Во многих случаях потеря или повреждени?
Освоение безопасной для типов JSON-сериализации в TypeScript...

Практически каждое веб-приложение нуждается в сериализации данных. Такая потребность возникает в следующих ситуациях:

  • передача данных по сети (например, HTTP-запросы, веб-сокеты);
  • эмбеддинг данных в HTML (например, для гидратации);
  • хранение данных в постоянном хранилище (например, LocalStorage);
  • обмен данными между процессами (например, между веб-воркерами или окнами/вкладками браузера с помощью postMessage).

Во многих случаях потеря или повреждение данных может привести к серьезным последствиям. Поэтому необходимо обеспечить удобный и безопасный механизм сериализации, который поможет обнаружить как можно больше ошибок на этапе разработки. Для этих целей удобно использовать JSON в качестве формата передачи данных и TypeScript для статической проверки кода во время разработки.

TypeScript является расширенной версией JavaScript, что должно обеспечивать беспрепятственное использование таких функций, как JSON.stringify и JSON.parse, не так ли? Однако, несмотря на все свои преимущества, TypeScript не понимает, что такое JSON и какие типы данных безопасны для сериализации и десериализации в JSON. Проиллюстрирую это на примере.

Проблема с JSON в TypeScript

Рассмотрим, к примеру, функцию, которая сохраняет некоторые данные в LocalStorage. Поскольку LocalStorage не может хранить объекты, используем сериализацию JSON:

interface PostComment {
authorId: string;
text: string;
updatedAt: Date;
}

function saveComment(comment: PostComment) {
const serializedComment = JSON.stringify(comment);
localStorage.setItem('draft', serializedComment);
}

Нам также понадобится функция для получения данных из LocalStorage.

function restoreComment(): PostComment | undefined {
const text = localStorage.getItem('draft');
return text ? JSON.parse(text) : undefined;
}

Что не так с этим кодом? Первая проблема заключается в том, что при восстановлении комментария получим тип string вместо Date для поля updatedAt.

Это происходит потому, что в JSON есть только четыре примитивных типа данных (null, string, number и boolean), а также массивы и объекты. В JSON невозможно сохранить объект Date, как и другие объекты, встречающиеся в JavaScript: функции, Map, Set и т. д.

Когда JSON.stringify сталкивается со значением, которое не может быть представлено в формате JSON, происходит приведение типов. В случае с объектом Date получаем строку, потому что объект Date реализует метод toJson(), который возвращает строку, а не объект Date.

const date = new Date('August 19, 1975 23:15:30 UTC');

const jsonDate = date.toJSON();
console.log(jsonDate);
// Ожидаемый вывод: "1975-08-19T23:15:30.000Z"

const isEqual = date.toJSON() === JSON.stringify(date);
console.log(isEqual);
// Ожидаемый вывод: true

Вторая проблема заключается в том, что функция saveComment возвращает тип PostComment, в котором поле даты имеет тип Date. Но мы уже знаем, что на самом деле вместо Date получим тип string. TypeScript мог бы помочь найти эту ошибку, но почему он этого не делает?

Оказывается, в стандартной библиотеке TypeScript функция JSON.parse типизирована как (text: string) => any. Из-за использования any проверка типов, по сути, отключена. В нашем примере TypeScript просто поверил нам на слово, что функция вернет PostComment, содержащий объект Date.

Такое поведение TypeScript неудобно и небезопасно. Приложение может “упасть” при попытке обращаться со строкой как с объектом Date. Например, может произойти сбой при вызове comment.updatedAt.toLocaleDateString().

Правда, в приведенном выше примере можно было просто заменить объект Date на числовую временную метку, что хорошо работает при сериализации JSON. Но в реальных приложениях объекты данных могут оказаться обширными, а типы  —  определенными в нескольких местах, и выявление такой ошибки в процессе разработки станет сложной задачей.

А что, если улучшить понимание JSON в TypeScript?

Работа с сериализацией

Для начала разберемся с тем, как заставить TypeScript понимать, какие типы данных можно безопасно сериализовать в JSON. Предположим, нам надо создать функцию safeJsonStringify, в которой TypeScript будет проверять формат входных данных, чтобы убедиться, что они сериализуемы в JSON.

function safeJsonStringify(data: JSONValue) {
return JSON.stringify(data);
}

В этой функции наиболее важной частью является тип JSONValue, который представляет все возможные значения, которые могут быть представлены в формате JSON. Реализация довольно проста:

type JSONPrimitive = string | number | boolean | null | undefined;

type JSONValue = JSONPrimitive | JSONValue[] | {
[key: string]: JSONValue;
};

Прежде всего определяем тип JSONPrimitive, который описывает все примитивные типы данных JSON. Включаем также тип undefined, чтобы учесть тот факт, что при сериализации ключи со значением undefined будут опущены. При десериализации эти ключи просто не появятся в объекте, что в большинстве случаев одно и то же.

Далее опишем тип JSONValue. Этот тип использует способность TypeScript описывать рекурсивные типы, то есть типы, которые ссылаются сами на себя. Здесь JSONValue может быть либо JSONPrimitive, либо массивом JSONValue, либо объектом, все значения которого относятся к типу JSONValue. В результате переменная типа JSONValue может содержать массивы и объекты с неограниченной вложенностью. Значения в них также будут проверяться на совместимость с форматом JSON.

Теперь протестируем функцию safeJsonStringify на следующих примерах:

// Нет ошибок
safeJsonStringify({
updatedAt: Date.now()
});

// Выдает ошибку:
// Аргумент типа '{ updatedAt: Date; }' не может быть присвоен параметру типа 'JSONValue'.
// Типы свойства 'updatedAt' несовместимы.
// Тип 'Date' не может быть присвоен типу 'JSONValue'.
safeJsonStringify({
updatedAt: new Date();
});

Кажется, все работает правильно. Функция позволяет передать дату в виде числа, но выдает ошибку при передаче объекта Date.

Однако рассмотрим более реалистичный пример, в котором данные, передаваемые в функцию, хранятся в переменной и имеют тип, содержащий описание.

interface PostComment {
authorId: string;
text: string;
updatedAt: number;
};

const comment: PostComment = {...};

// Выдает ошибку:
// Аргумент типа 'PostComment' не может быть присвоен параметру типа 'JSONValue'.
// Тип 'PostComment' не может быть присвоен типу '{ [key: string]: JSONValue; }'.
// В типе 'PostComment' отсутствует индексная сигнатура для типа 'string'.
safeJsonStringify(comment);

Теперь все становится несколько сложнее. TypeScript не позволяет присвоить переменную типа PostComment параметру функции типа JSONValue, поскольку в типе PostComment отсутствует индексная сигнатура для типа string.

Итак, что такое индексная сигнатура и почему она отсутствует? Помните, как мы описывали объекты, которые можно сериализовать в формат JSON?

type JSONValue = {
[key: string]: JSONValue;
};

В данном случае [key: string]  —  это индексная сигнатура. Это означает следующее: данный объект может иметь любые ключи в виде строк, значения которых обладают типом JSONValue. Получается, нам нужно добавить индексную сигнатуру к типу PostComment?

interface PostComment {
authorId: string;
text: string;
updatedAt: number;

// Не делайте этого:
[key: string]: JSONValue;
};

На самом деле это означает, что комментарий может содержать любые произвольно выбранные поля. Это, как правило, не является желаемым результатом при определении типов данных в приложении.

Реальное решение проблемы индексных сигнатур следует искать в использовании сопоставленных типов (mapped types), которые позволяют рекурсивно перебирать поля даже для типов, у которых не определена индексная сигнатура. В сочетании с дженериками эта возможность позволяет преобразовать любой тип данных T в другой тип JSONCompatible<T>, совместимый с форматом JSON. 

type JSONCompatible<T> = unknown extends T ? never : {
[P in keyof T]:
T[P] extends JSONValue ? T[P] :
T[P] extends NotAssignableToJson ? never :
JSONCompatible<T[P]>;
};

type NotAssignableToJson =
| bigint
| symbol
| Function;

Тип JSONCompatible<T>  —  это сопоставленный тип, который проверяет, может ли данный тип T быть безопасно сериализован в JSON. Для этого он перебирает каждое свойство типа T и выполняет следующие действия.

  • Условный тип T[P] extends JSONValue ? T[P] : ... проверяет, совместим ли тип свойства с типом JSONValue, следя за тем, чтобы его можно было безопасно преобразовать в JSON. Если это так, тип свойства остается неизменным.
  • Условный тип T[P] extends NotAssignableToJson ? never : ... проверяет, не может ли тип свойства быть присвоен JSON. В этом случае тип свойства преобразуется в never, эффективно отфильтровывая свойство от конечного типа.
  • Если ни одно из этих условий не выполняется, тип проверяется рекурсивно, пока не будет сделан вывод. Этот способ работает, даже если тип не имеет индексной сигнатуры.
  • Проверка unknown extends T ? never :... в начале используется для предотвращения преобразования типа unknown в пустой тип объекта {}, который по сути эквивалентен типу any.

Еще одним интересным аспектом является тип NotAssignableToJson. Он состоит из двух примитивов TypeScript (bigint и symbol) и типа Function, который описывает любую возможную функцию. Тип Function очень важен для отсеивания значений, которые нельзя присвоить JSON. Это связано с тем, что любой сложный объект в JavaScript основан на типе Object и имеет хотя бы одну функцию в своей цепочке прототипов (например, toString()). Тип JSONCompatible проводит итерацию по всем этим функциям, поэтому проверки функций достаточно, чтобы отсеять все, что не сериализуется в JSON.

Теперь используем этот тип в функции сериализации:

function safeJsonStringify<T>(data: JSONCompatible<T>) {
return JSON.stringify(data);
}

Функция использует обобщенный параметр T, который является расширением типа JSONCompatible<T>. Это означает, что она принимает аргумент data типа T, который должен быть типом, совместимым с JSON. В результате можно использовать функцию с типами данных, у которых отсутствует индексная сигнатура.

interface PostComment {
authorId: string;
text: string;
updatedAt: number;
}

function saveComment(comment: PostComment) {
const serializedComment = safeJsonStringify(comment);
localStorage.setItem('draft', serializedComment);
}

Этот подход можно использовать во всех случаях, когда необходима сериализация JSON, например при передаче данных по сети, эмбеддинге данных в HTML, хранении данных в localStorage, передаче данных между веб-воркерами и т. д. Кроме того, помощник toJsonValue можно использовать, когда строго типизированный объект без индексной сигнатуры необходимо присвоить переменной типа JSONValue.

function toJsonValue<T>(value: JSONCompatible<T>): JSONValue {
return value;
}

const comment: PostComment = {...};

const data: JSONValue = {
comment: toJsonValue(comment)
};

В этом примере использование toJsonValue позволяет обойти ошибку, связанную с отсутствием индексной сигнатуры в типе PostComment.

Работа с десериализацией

Когда дело доходит до десериализации, задача одновременно и упрощается, и усложняется, поскольку включает как проверки статического анализа, так и проверки формата полученных данных во время выполнения.

С точки зрения системы типов TypeScript, задача довольно проста. Рассмотрим следующий пример:

function safeJsonParse(text: string) {
return JSON.parse(text) as unknown;
}

const data = JSON.parse(text);
// ^? unknown

В данном случае заменяем возвращаемый тип any на тип unknown. Почему выбран тип unknown? По сути, строка JSON может содержать что угодно, а не только те данные, которые мы ожидаем получить. Например, формат данных может меняться в разных версиях приложения, или другая часть приложения может записывать данные в тот же ключ LocalStorage. Поэтому unknown  —  самый безопасный и точный выбор.

Однако работа с типом unknown менее удобна, чем простое указание нужного типа данных. Помимо приведения типов, существует множество способов преобразования типа unknown в нужный тип данных. Один из таких способов  —  использование библиотеки Superstruct, которая проверяет данные во время выполнения и выдает подробно описанные ошибки, если данные недопустимы.

import { create, object, number, string } from 'superstruct';

const PostComment = object({
authorId: string(),
text: string(),
updatedAt: number(),
});

// Примечание: больше не нужно вручную указывать возвращаемый тип
function restoreDraft() {
const text = localStorage.getItem('draft');
return text ? create(JSON.parse(text), PostComment) : undefined;
}

Здесь функция create выступает в роли защитника типов, сужая тип до нужного интерфейса Comment. Следовательно, больше не нужно вручную указывать возвращаемый тип.

Реализация безопасного варианта десериализации  —  это только половина дела. Не менее важно не забыть использовать ее при решении следующей задачи в проекте. Дело усложняется, если над проектом работает большая команда, так как обеспечить соблюдение всех соглашений и лучших практик может быть непросто.

Typescript-eslint поможет решить эту задачу. Этот инструмент способствует выявлению всех случаев небезопасного использования any. В частности, можно найти все случаи использования JSON.parse и быть уверенным в том, что формат полученных данных проверен.

Заключение

Вот еще полезные функции и типы, предназначенные для безопасной сериализации и десериализации JSON. Можете протестировать их в подготовленном TS Playground.

type JSONPrimitive = string | number | boolean | null | undefined;

type JSONValue = JSONPrimitive | JSONValue[] | {
[key: string]: JSONValue;
};

type NotAssignableToJson =
| bigint
| symbol
| Function;

type JSONCompatible<T> = unknown extends T ? never : {
[P in keyof T]:
T[P] extends JSONValue ? T[P] :
T[P] extends NotAssignableToJson ? never :
JSONCompatible<T[P]>;
};

function toJsonValue<T>(value: JSONCompatible<T>): JSONValue {
return value;
}

function safeJsonStringify<T>(data: JSONCompatible<T>) {
return JSON.stringify(data);
}

function safeJsonParse(text: string): unknown {
return JSON.parse(text);
}

Их можно использовать в любой ситуации, когда необходима сериализация JSON.

Я использую эту стратегию в своих проектах уже несколько лет, и она показала свою эффективность, своевременно обнаруживая потенциальные ошибки во время разработки приложения.