Encode, don’t stringify — как JSON.stringify вам врёт

—

от автора

Помните, как лет 5-7 назад все сидели на Yup или Ajv и всех всё устраивало? Признаю, половину интернета устраивает до сих пор 🫡

Потом появилось “Parse, don’t validate”, стало настольной книгой для кучи разработчиков и в итоге выстрелило и у нас, в JavaScript и TypeScript. Тысячи библиотек со схемами — лучшее тому подтверждение 😁

Но это уже решённая проблема, и статью вы открыли не ради неё. А вот что не решено:

  • “Encode, don’t stringify”

Или, проще говоря: НИКОГДА не используйте JSON.stringify в своём коде. — Да, сегодня это звучит дерзко!

Сейчас докажу — и, надеюсь, сделаю эту статью для JavaScript-экосистемы тем же, чем в 2019-м стало “Parse, don’t validate”. Поехали!

FAQ перед стартом

«Никогда? Совсем?» Да. Ему место только внутри энкодера, как детали реализации, которую вы в своём коде не видите.

Кто ты вообще такой? Я Дмитрий — делаю опенсорс с упором на DX и производительность, состою в команде ReScript Lang и написал Sury, про который тоже будет дальше.

«Так это реклама?» Частично. Проблема, о которой пойдёт речь, реальная, и обзор решений будет честным. Sury — библиотека, которую я делаю последние 4.5 года, и я правда считаю её лучшим инструментом на сегодня. Но важна в первую очередь сама идея: как это было с “Parse, don’t validate”, со временем альтернатив станет только больше.

Буду благодарен за репост, если статья зайдёт 🙏 Давайте раскачаем “Encode, don’t stringify”!

Часть 1: сплошное враньё

Что не так с JSON.stringify, спросите вы? С функцией, которая есть вообще в каждом проекте. Сейчас посчитаем. Кажется, за 7 лет разработки я собрал все грабли, какие можно. Напишите в комментариях, если вам JSON.stringify ни разу не соврал — я удивлюсь 👀

Он возвращает undefined

JSON.stringify(undefined);// => undefinedJSON.stringify(() => {});// => undefined

Кто-то скажет: ну и ладно. Но посмотрите на тип возвращаемого значения:

stringify(value: any, replacer?: ..., space?: ...): string;

Особую прелесть добавляет any на входе. Думаю, дальше объяснять не надо.

Есть отличный ts-reset, но здесь он не поможет: он чинит JSON.parse, а для JSON.stringify правила нет вообще.

Он падает

JSON.stringify({ id: 1n });// => TypeError: Do not know how to serialize a BigIntconst user = {};user.self = user;JSON.stringify(user);// => TypeError: Converting circular structure to JSONlet root = {},  node = root;for (let i = 0; i < 50000; i++) node = node.next = {};JSON.stringify(root);// => RangeError: Maximum call stack size exceeded

Последние два — уже экзотика и не совсем про тему статьи, но пусть тоже полежат в копилке необработанных ошибок.

По ошибке непонятно, где искать

А когда он всё-таки падает, начинаются прятки:

JSON.stringify({ user: { orders: [{ total: 1n }] } });// => TypeError: Do not know how to serialize a BigInt

Какой заказ, какое поле — непонятно, и стектрейс покажет только res.json(). Спасибо, конечно.

Он молча портит данные

А вот на этом хочу остановиться подробнее — это то, что реально стоит денег. Ни падения, ни предупреждения, и заметить очень тяжело:

JSON.stringify({ price: Infinity }); // => '{"price":null}'JSON.stringify({ price: NaN }); // => '{"price":null}'// Где-то переполнилась математика, а на клиенте пустая ячейка или падениеJSON.stringify({ a: undefined, b: 1 }); // => '{"b":1}'      ключ пропалJSON.stringify([1, undefined, 2]); // => '[1,null,2]'   то же значение, но уже nullJSON.stringify([1, () => {}, 2]); // => '[1,null,2]'// Для вас, может, и ожидаемо, а вот проверка T | null на той стороне не согласитсяJSON.stringify({ m: new Map([["a", 1]]) }); // => '{"m":{}}'JSON.stringify({ s: new Set([1, 2]) }); // => '{"s":{}}'// Данные просто исчезли, ошибки нетJSON.stringify({ b: new Uint8Array([1, 2, 3]) });// => '{"b":{"0":1,"1":2,"2":3}}'// Массив байтов превратился в словарь, с которым непонятно что делать

Самое неприятное — что всё это проходит незаметно, и TypeScript тут не всегда прикроет.

Он отправляет всё подряд

JSON.stringify({ login: "hello", _internalSecret: "1232" });// => '{"login":"hello","_internalSecret":"1232"}'

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

Часть 2: encode, don’t stringify

Раз вы дочитали досюда, скорее всего с каким-нибудь враньём JSON.stringify вы уже сталкивались. Рад видеть 🤝

Решение простое, и мысль ровно та же, что в “Parse, don’t validate”: JSON.stringify не знает, какими ваши данные должны быть, поэтому додумывает сам. Вместо этого на каждый тип, который мы отправляем наружу, заводится энкодер — он безопасно превращает значение в валидный JSON или сразу в строку:

type user = {  name : string;  age : int;}let user_to_json { name; age } =  `Assoc [    ("name", `String name);    ("age", `Int age);  ]let json = user_to_json { name = "Alice"; age = 42 }let output = Yojson.Safe.to_string json(* {"name":"Alice","age":42} *)

Пример на OCaml я взял, потому что он хорошо показывает саму суть и то, как с этим годами живут в «серьёзных» языках. Разница лишь в том, что на практике энкодеры обычно генерируются из описания типов — как это делает Typia у нас.

Мне ближе вариант со схемой в рантайме. Все современные JS-библиотеки схем и так уже источник правды для моделей данных, с выводом типов из коробки. А раз схема описывает данные на входе, она может описать их и на выходе — из того же определения.

Именно это Sury сделал первым в мире JS-схем. Одна схема, оба направления:

import * as S from "sury";const schema = S.schema({  id: S.bigint,  at: S.date,  price: S.number,});//? Schema<{id: bigint, at: Date, price: number}, {id: bigint, at: Date, price: number}>const encodeToJsonString = S.encoder(schema, S.jsonString);//? (data: {id: bigint, at: Date, price: number}) => stringencodeToJsonString({  id: 9007199254740993n,  at: new Date("2026-01-15T10:30:00.000Z"),  price: 9.99,});// => '{"id":"9007199254740993","at":"2026-01-15T10:30:00.000Z","price":9.99}'// илиconst encodeToJson = S.encoder(schema, S.json);//? (data: {id: bigint, at: Date, price: number}) => JSON// или (скоро)const encodeToBase64url = S.encoder(schema, S.base64url);//? (data: {id: bigint, at: Date, price: number}) => string// или безопасно раскодировать обратноS.decoder(  S.jsonString,  schema,)('{"id":"9007199254740993","at":"2026-01-15T10:30:00.000Z","price":9.99}');// => {id: 9007199254740993n, at: new Date("2026-01-15T10:30:00.000Z"), price: 9.99}

Типы, которые JSON.stringify не переваривает, здесь преобразуются по логике самой схемы. Невалидные значения честно падают с путём до поля, а не портятся молча. Лишние поля отрезаются. И всё это удобно, компактно, быстро и типобезопасно.

encodeToJsonString({ id: 1n, at: new Date(), price: Infinity });// => throws SuryError: Failed at ["price"]: Expected JSON, received Infinity

Промежуточного объекта тоже нет — encodeToJsonString собирается ровно под эту форму данных:

(i) => {  let v0 = i["price"];  return (    '{"id":"' +    i["id"] +    '","at":"' +    i["at"].toISOString() +    '","price":' +    (Number.isFinite(v0) ? v0 : e[1](v0)) +    "}"  );};

Это весь энкодер целиком. Единственная проверка, которая осталась в рантайме, — Number.isFinite: при парсинге из unknown такое значение допустимо, а при кодировании в JSON уже нет.

Часть 3: что ещё есть на рынке

Идея собирать энкодер из схемы не новая, так что давайте пройдёмся по всему, что есть сегодня — чтобы картина была полной.

Заброшенные. compile-json-stringify и slow-json-stringify не трогали с 2022 года, а @deepkit/type работает на этапе сборки, и последний релиз у него был в сентябре 2025. Чем это грозит: slow-json-stringify не экранирует кавычки, поэтому { name: 'he said "hi"' } превращается в {"name":"he said "hi""} — такое не распарсит никто, и баг лежит там уже четыре года. Брать не советую.

Прототип от команды ElysiaJS. json-accelerator — любопытная штука от ребят, сделавших один из самых быстрых HTTP-фреймворков в экосистеме. Но в сам фреймворк он так и не попал, релизов не было с апреля 2025, и валидации там нет вообще: что передали, то он и приведёт к типу, так что { price: Infinity } на выходе даёт {"price":Infinity} — это даже не валидный JSON. Цель проекта была ускорить JSON.stringify, а не починить корректность, так что его тоже мимо.

Транспортные форматы. devalue (SvelteKit) и superjson (tRPC) — гиганты, вместе около 20 млн загрузок в неделю — и они решают другую задачу:

const value = {  at: new Date("2026-01-15T10:30:00Z"),  price: Infinity,  secret: "LEAKED",};devalue.stringify(value);// => [{"at":1,"price":-4,"secret":2},["Date","2026-01-15T10:30:00.000Z"],"LEAKED"]superjson.stringify(value);// => {"json":{"at":"2026-01-15T10:30:00.000Z","price":"Infinity","secret":"LEAKED"},//     "meta":{"values":{"at":["Date"],"price":["number"]},"v":1}}

У них bigint, Date, Map и даже циклические ссылки спокойно доезжают туда и обратно — за счёт собственного формата. Я бы рекомендовал этот вариант за простоту, когда оба конца ваши, а структуру данных вы заранее не знаете. Но это именно свой формат: сторонний потребитель его не поймёт, а без схемы вы получаете ровно ту же проблему неконтролируемых данных, что и с JSON.stringify.

typia читает ваши TypeScript-типы на сборке и генерирует из них сериализатор. typia.json.assertStringify<T>() сначала валидирует и показывает конкретное поле — invalid type on $input.id — так что здесь всё честно и безопасно, а в рантайме почти ничего не стоит, потому что весь код заинлайнен. Плата — сборка: нужен ttsc или unplugin, из чистого JavaScript это не запустить, и никакого объекта схемы в рантайме нет, передать его куда-то не получится. Плюс bigint в JSON-функциях запрещён явно, так что для него снова руками. Если вам вообще нравятся инструменты на этапе компиляции — берите.

Effect Schema живёт с двунаправленными кодеками уже давно, и в v4 у него самый сильный ответ после Sury. Идиоматичный путь — toCodecJson, который делает из любой схемы JSON-совместимую:

const codec = S.toCodecJson(S.Struct({ id: S.BigInt, at: S.Date }));S.encodeSync(codec)({ id: 42n, at: new Date("2026-01-15T10:30:00Z") });// => { id: "42", at: "2026-01-15T10:30:00.000Z" }

bigint, Date и Uint8Array разруливаются сами, и в описании схемы не появляется ничего про транспорт. Единственное, что стоит держать в голове, — как он поступает со значениями, которых в JSON быть не может: Infinity уезжает строкой "Infinity" и обратно раскодируется без потерь. Если на обоих концах Effect — отлично, а вот потребитель на другом языке, который ждал там число, удивится. Sury в такой ситуации падает, и для публичного API это, на мой взгляд, безопаснее, но назвать поведение Effect враньём было бы нечестно: ничего не теряется.

При всём этом роасте Effect v4 я бы назвал лучшей TypeScript-библиотекой 2026 года, а Effect Schema — очевидным выбором, если Effect у вас уже есть.

Zod обзавёлся кодеками в 4.1 и подобрался ближе всех:

z.encode(schema, { price: Infinity, name: "a" }); // ❌ throws

Правда, работает это только потому, что z.number() и так не пропускает NaN и Infinity, а не потому, что он в курсе, что на выходе JSON. А для bigint или Date кодеки придётся дописывать руками прямо в схеме. Это не страшно — главное, что проблема небезопасного JSON.stringify закрывается полностью. Отдельной цели «JSON-строка» тоже нет, но после encode по схеме звать JSON.stringify уже не опасно. Если вы на Zod — переходите на энкодеры вместо голого stringify.

fast-json-stringify (тот самый, от Fastify) — самый популярный, но часть вранья он сохраняет и добавляет своего:

const stringify = fastJson({  type: "object",  properties: { price: { type: "number" }, name: { type: "string" } },  required: ["price", "name"],});stringify({ price: Infinity, name: "a" }); // => '{"price":null,"name":"a"}'   всё ещё портитstringify({ price: NaN, name: "a" }); // => throws: The value "NaN" cannot be converted to a numberstringify({ price: 1, name: 42 }); // => '{"price":1,"name":"42"}'     молча привёл тип

Приведение типов здесь не баг — в типах прямо написано StringCoercible = string | Date | RegExp — но я не сразу понял, что происходит.

Главная причина такого поведения в том, что схема до TypeScript вообще не доходит:

// fast-json-stringify — <TDoc extends object = object>(doc: TDoc) => stringstringify({ totally: "unrelated", nonsense: 123 }); // ✅ компилируется, strict mode// Sury — (data: { price: number; name: string }) => stringencode({ totally: "unrelated", nonsense: 123 });// => TS2353: 'totally' does not exist in type '{ price: number; name: string }'

В эпоху AI игнорировать типы, которые уже есть в схеме, — прямой путь к весёлым багам. У Sury, кстати, есть S.fromJSONSchema, который корректно выводит типы даже из рекурсивных JSON Schema. fast-json-stringify я бы не советовал: ни производительности, ни гарантий он не добавляет.

В общем, всё рядом:

Кодируем объект { price, name }

Sury

Effect Schema

Zod

typia

fast-json-stringify

Infinity

✅ падает с путём

⭕ строка "Infinity"

✅ падает с путём

✅ падает с путём

❌ null

Неверный тип

✅ падает с путём

✅ падает с путём

✅ падает с путём

✅ падает с путём

❌ молча приводит

Пропущенное поле

✅ падает с путём

✅ падает с путём

✅ падает с путём

✅ падает с путём

✅ падает (только имя)

bigint / Date как нормальные типы

✅

✅ автоматически

✅ руками

❌ bigint запрещён

❌

Лишние поля

✅ отрезаются

✅ отрезаются

✅ отрезаются

✅ отрезаются

✅ отрезаются

Схема доходит до TypeScript

✅ выводится

✅ выводится

✅ выводится

✅ это и есть тип

❌ любой объект

Умеет обратно

✅ та же схема

✅

✅

✅

❌

Работает без шага сборки

✅

✅

✅

❌ компилятор

✅

min+gzip

16.4 kB

23.5 kB

19.4 kB

заинлайнено

56.7 kB

Размеры посчитаны с tree-shaking. fast-json-stringify — единственный, кто портит и приводит данные молча; Ajv перед ним решает это примерно за 46 байт, потому что зависимость на Ajv у него и так есть.

Часть 4: «а кодирование не тормозит?»

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

Полный бенчмарк, все строки, включая те, где я проигрываю:

Кодируем в JSON-строку

Sury

JSON.stringify

Effect

Zod

typia

fast-json-stringify

devalue / superjson

Ответ API (профиль пользователя, 7 полей)

262 ns

413 ns

3.19 µs

530 ns

277 ns

279 ns

2.36 — 3.68 µs

Списочный эндпоинт (100 строк)

10.20 µs

10.15 µs

67.80 µs

16.90 µs

10.62 µs

10.92 µs

121 — 196 µs

Лента событий (50 tagged-union событий)

3.49 µs

4.74 µs

41.36 µs

9.77 µs

6.12 µs

13.15 µs

52 — 96 µs

Словарь метрик (50 чисел)

8.26 µs

4.70 µs

22.30 µs

14.70 µs

19.92 µs

8.98 µs

24 — 34 µs

Словарь лейблов (50 строк)

3.80 µs

3.92 µs

11.71 µs

14.49 µs

19.18 µs

7.96 µs

23 — 33 µs

bigint id + бинарный payload + Date

989 ns

1.13 µs

3.57 µs

1.48 µs

1.15 µs

1.13 µs

3.28 — 5.48 µs

Важно! Я не зову переходить на Sury ради наносекунд. Суть в том, что JSON.stringify небезопасен, а починить это ничего не стоит: просадки по скорости не будет, а на большинстве реальных данных станет даже быстрее. Главная причина — безопасность, а скорость можно принести команде бонусом, если решите переезжать.

Остальные библиотеки пока медленнее голого JSON.stringify, но не настолько, чтобы это стало проблемой для большинства проектов. Мне важно было показать, что быстрее — возможно, и Sury это доказывает. Думаю, за пару лет остальные подтянутся, и кодирование станет в экосистеме такой же нормой, как в своё время парсинг.

Часть 5: как я обогнал JSON.stringify с аппаратным ускорением

JSON.stringify — это C++ внутри движка, который вылизывали двадцать лет. Рассказываю, за счёт чего Sury всё-таки быстрее.

JIT. Sury генерирует оптимизированный код в рантайме через new Function. Подход обкатанный, известных проблем с безопасностью нет. Так же работают TypeBox, Zod v4 и ArkType, да и Cloudflare Workers недавно разрешили eval.

Схема уже сделала всю работу. JSON.stringify на каждом вызове заново выясняет форму объекта: обходит ключи, смотрит тип каждого значения, экранирует строки. Sury делает это один раз — когда вы создаёте энкодер. В рантайме остаётся конкатенация строк, а её движки оптимизируют ничуть не хуже.

Никакого промежуточного объекта. Вот про это обычно забывают. Даже если писать мапперы руками, вы сначала собираете новый объект — { id: String(id), at: at.toISOString() } — и отдаёте его в JSON.stringify, который обходит его заново. Аллокация плюс второй проход. Sury сразу пишет строку.

А там, где JSON.stringify выигрывает, Sury просто зовёт его. Длинные строки, форматированный вывод, поддеревья, которые и так обычный JSON. Гордость тут ни при чём 😁

Переписывать проект не придётся

Возможно, вам нравится Zod, Valibot или TypeBox, и вряд ли вы побежите переезжать только потому, что какой-то незнакомец на dev.to разнёс JSON.stringify…

И не надо. Всё, что умеет отдавать JSON Schema, может отдать свои схемы в Sury — и получить обратно типизированный, скомпилированный и безопасный энкодер:

const surySchema = S.fromJSONSchema(  yourExistingSchema["~standard"].jsonSchema.input({ target: "draft-07" }),);S.encoder(surySchema, S.jsonString); // безопасный энкодер в JSON-строкуS.encoder(surySchema, S.json); // безопасный энкодер в JSONS.encoder(S.any, surySchema, S.json); // то же самое, но с валидациейS.assert(surySchema, data); // быстрая валидация с путями в ошибках

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

(Если Standard JSON Schema вам пока ни о чём не говорит: это спека, благодаря которой всё это работает между библиотеками, которые друг о друге не знают. Подписывайтесь — про неё будет отдельная статья.)

Подводя итог

Я не утверждаю, что у вас всё сломано. Куча софта прекрасно живёт на JSON.stringify все пятнадцать с лишним лет, что он существует.

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

Не доверять непроверенным данным на входе мы научились давно. Пора перестать доверять им и на выходе.

Encode, don’t JSON.stringify. — Cheers 🙏


Sury лежит на GitHub — звёздочка правда помогает и мотивирует делать дальше. Есть вопросы — заводите issue или пишите в X. 🧬

ссылка на оригинал статьи https://habr.com/ru/articles/1070394/