Освоение безопасной для типов 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.
Я использую эту стратегию в своих проектах уже несколько лет, и она показала свою эффективность, своевременно обнаруживая потенциальные ошибки во время разработки приложения.