Помните, как лет 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); // => undefined JSON.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 BigInt const user = {}; user.self = user; JSON.stringify(user); // => TypeError: Converting circular structure to JSON let 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]' то же значение, но уже null JSON.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}) => string encodeToJsonString({ 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 number stringify({ price: 1, name: 42 }); // => '{"price":1,"name":"42"}' молча привёл тип
Приведение типов здесь не баг — в типах прямо написано StringCoercible = string | Date | RegExp — но я не сразу понял, что происходит.
Главная причина такого поведения в том, что схема до TypeScript вообще не доходит:
// fast-json-stringify — <TDoc extends object = object>(doc: TDoc) => string stringify({ totally: "unrelated", nonsense: 123 }); // ✅ компилируется, strict mode // Sury — (data: { price: number; name: string }) => string encode({ totally: "unrelated", nonsense: 123 }); // => TS2353: 'totally' does not exist in type '{ price: number; name: string }'
В эпоху AI игнорировать типы, которые уже есть в схеме, — прямой путь к весёлым багам. У Sury, кстати, есть S.fromJSONSchema, который корректно выводит типы даже из рекурсивных JSON Schema. fast-json-stringify я бы не советовал: ни производительности, ни гарантий он не добавляет.
В общем, всё рядом:
Кодируем объект | Sury | Effect Schema | Zod | typia | fast‑json‑stringify |
|---|---|---|---|---|---|
| ✅ падает с путём | ⭕ строка | ✅ падает с путём | ✅ падает с путём | ❌ |
Неверный тип | ✅ падает с путём | ✅ падает с путём | ✅ падает с путём | ✅ падает с путём | ❌ молча приводит |
Пропущенное поле | ✅ падает с путём | ✅ падает с путём | ✅ падает с путём | ✅ падает с путём | ✅ падает (только имя) |
| ✅ | ✅ автоматически | ✅ руками | ❌ | ❌ |
Лишние поля | ✅ отрезаются | ✅ отрезаются | ✅ отрезаются | ✅ отрезаются | ✅ отрезаются |
Схема доходит до 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 |
| 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 |
| 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); // безопасный энкодер в JSON S.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. 🧬

