paint-brush
Освоение типобезопасной сериализации JSON в TypeScriptк@nodge
21,226 чтения
21,226 чтения

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

к Maksim Zemskov11m2024/02/26
Read on Terminal Reader

Слишком долго; Читать

В этой статье рассматриваются проблемы сериализации данных в TypeScript при использовании формата JSON. Особое внимание уделяется недостаткам функций JSON.stringify и JSON.parse. Для решения этих проблем предлагается использовать тип JSONCompatible, чтобы проверить, можно ли безопасно сериализовать тип T в JSON. Кроме того, он рекомендует использовать библиотеку Superstruct для безопасной десериализации из JSON. Этот метод повышает безопасность типов и позволяет обнаруживать ошибки во время разработки.
featured image - Освоение типобезопасной сериализации JSON в TypeScript
Maksim Zemskov HackerNoon profile picture
0-item
1-item
2-item


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


  • Передача данных по сети (например, HTTP-запросы, WebSockets)
  • Встраивание данных в 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; }


Что не так с этим кодом? Первая проблема заключается в том, что при восстановлении комментария мы получим для поля 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 и выполнения следующих действий:


  1. T[P] extends JSONValue ? T[P] : ... условный тип проверяет, совместим ли тип свойства с типом JSONValue , гарантируя, что его можно безопасно преобразовать в JSON. В этом случае тип свойства остается неизменным.
  2. T[P] extends NotAssignableToJson ? never : ... условный тип проверяет, нельзя ли назначить тип свойства JSON. В этом случае тип свойства преобразуется в never , что эффективно отфильтровывает свойство из окончательного типа.
  3. Если ни одно из этих условий не выполняется, тип проверяется рекурсивно до тех пор, пока не будет сделан вывод. Таким образом, это работает, даже если тип не имеет индексной подписи.


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.


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


Я надеюсь, что эта статья предоставила вам новые идеи. Спасибо за чтение!

Полезные ссылки