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

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

27 февраля 2024 г.

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

* Передача данных по сети (например, 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;
}

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

Это происходит потому, что 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 проверка типов практически отключена. В нашем примере 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. Мы также включаем тип unопределенный, поскольку при сериализации ключи со значением unопределенный будут опущены. При десериализации эти ключи просто не появятся в объекте, что в большинстве случаев одно и то же.

Далее мы опишем тип 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, поскольку «Подпись индекса для типа 'string' отсутствует в типе 'PostComment' ".

Итак, что такое индексная подпись и почему она отсутствует? Помните, как мы описывали объекты, которые можно сериализовать в формат 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;
};

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

Реальное решение проблемы с сигнатурой индекса можно найти в Сопоставленных типах, которые позволяют рекурсивное перебор полей, даже для типов, для которых не определена сигнатура индекса. В сочетании с дженериками эта функция позволяет конвертировать любой тип данных 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] расширяет JSONValue? T[P] : ... условный тип проверяет, совместим ли тип свойства с типом JSONValue, гарантируя, что его можно безопасно преобразовать в JSON. В этом случае тип свойства остается неизменным.
  2. T[P] расширяет NotAssignableToJson ? Never : ... условный тип проверяет, нельзя ли назначить тип свойства JSON. В этом случае тип свойства преобразуется в никогда, эффективно отфильтровывая свойство из окончательного типа.
  3. Если ни одно из этих условий не выполняется, тип проверяется рекурсивно до тех пор, пока не будет сделан вывод. Таким образом, это работает, даже если у типа нет индексной подписи.
  4. unknown расширяет T ? никогда :... проверка в начале используется для предотвращения преобразования типа unknown в пустой тип объекта {}, который по сути эквивалентен тип любой.

    Еще один интересный аспект — тип 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. Почему стоит выбрать неизвестно? По сути, строка 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 может помочь в этой задаче. Этот инструмент помогает выявить все случаи небезопасного использования любого. В частности, можно найти все случаи использования JSON.parse и гарантировать проверку формата полученных данных. Подробнее об избавлении от типа any в базе кода можно прочитать в статье Создание TypeScript. Действительно «строго типизированный».

    Заключение

    Here are the final utility functions and types designed to assist in safe JSON serialization and deserialization. You can test these in the prepared 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.

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

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

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


    Оригинал