Почти каждое веб-приложение требует сериализации данных. Такая необходимость возникает в таких ситуациях, как:
Во многих случаях потеря или повреждение данных может привести к серьезным последствиям, поэтому крайне важно обеспечить удобный и безопасный механизм сериализации, который помогает обнаружить как можно больше ошибок на этапе разработки. Для этих целей удобно использовать JSON в качестве формата передачи данных и TypeScript для статической проверки кода во время разработки.
TypeScript представляет собой расширенную версию JavaScript, которая должна обеспечить беспрепятственное использование таких функций, как JSON.stringify
и JSON.parse
, верно? Оказывается, несмотря на все свои преимущества, TypeScript, естественно, не понимает, что такое JSON и какие типы данных безопасны для сериализации и десериализации в JSON.
Проиллюстрируем это примером.
Рассмотрим, например, функцию, которая сохраняет некоторые данные в 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; }
Что не так с этим кодом? Первая проблема заключается в том, что при восстановлении комментария мы получим для поля updatedAt
string
тип вместо Date
.
Это происходит потому, что JSON имеет только четыре примитивных типа данных ( null
, string
, number
, boolean
), а также массивы и объекты. Невозможно сохранить объект Date
в JSON, а также другие объекты, которые встречаются в 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); // Expected output: "1975-08-19T23:15:30.000Z" const isEqual = date.toJSON() === JSON.stringify(date); console.log(isEqual); // Expected output: 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
, используя следующие примеры:
// No errors safeJsonStringify({ updatedAt: Date.now() }); // Yields an error: // Argument of type '{ updatedAt: Date; }' is not assignable to parameter of type 'JSONValue'. // Types of property 'updatedAt' are incompatible. // Type 'Date' is not assignable to type 'JSONValue'. safeJsonStringify({ updatedAt: new Date(); });
Кажется, все работает правильно. Функция позволяет нам передавать дату в виде числа, но выдает ошибку, если мы передаем объект Date
.
Но давайте рассмотрим более реалистичный пример, в котором данные, передаваемые в функцию, хранятся в переменной и имеют описанный тип.
interface PostComment { authorId: string; text: string; updatedAt: number; }; const comment: PostComment = {...}; // Yields an error: // Argument of type 'PostComment' is not assignable to parameter of type 'JSONValue'. // Type 'PostComment' is not assignable to type '{ [key: string]: JSONValue; }'. // Index signature for type 'string' is missing in type 'PostComment'. 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; // Don't do this: [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 и символ) и типа Function
, который описывает любую возможную функцию. Тип Function
имеет решающее значение для фильтрации любых значений, которые нельзя присвоить JSON. Это связано с тем, что любой сложный объект в JavaScript основан на типе Object и имеет хотя бы одну функцию в цепочке прототипов (например, toString()
). Тип JSONCompatible
выполняет итерацию по всем этим функциям, поэтому проверки функций достаточно, чтобы отфильтровать все, что не сериализуется в JSON.
Теперь давайте используем этот тип в функции сериализации:
function safeJsonStringify<T>(data: JSONCompatible<T>) { return JSON.stringify(data); }
Теперь функция использует общий параметр T
и принимает аргумент JSONCompatible<T>
. Это означает, что он принимает data
аргумента типа T
, который должен быть типом, совместимым с JSON. Теперь мы можем использовать функцию с типами данных без индексной подписи.
Функция теперь использует универсальный параметр 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(), }); // Note: we no longer need to manually specify the return type function restoreDraft() { const text = localStorage.getItem('draft'); return text ? create(JSON.parse(text), PostComment) : undefined; }
Здесь функция create
действует как защита типа, сужая тип до желаемого интерфейса Comment
. Следовательно, нам больше не нужно вручную указывать тип возвращаемого значения.
Реализация опции безопасной десериализации — это только половина дела. Не менее важно не забывать использовать его при решении следующей задачи проекта. Это становится особенно сложно, если над проектом работает большая команда, поскольку обеспечить соблюдение всех соглашений и передового опыта может быть сложно.
Typescript-eslint может помочь в этой задаче. Этот инструмент помогает выявить все случаи any
использования. В частности, можно найти все случаи использования JSON.parse
и гарантировать, что формат полученных данных проверен. Подробнее об избавлении от any
типа в кодовой базе можно прочитать в статье Making TypeScript Truly «Strongly Typeded» .
Вот окончательные служебные функции и типы, предназначенные для безопасной сериализации и десериализации 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.
Я использую эту стратегию в своих проектах уже несколько лет, и она продемонстрировала свою эффективность, оперативно выявляя потенциальные ошибки при разработке приложений.
Я надеюсь, что эта статья предоставила вам новые идеи. Спасибо за чтение!