Помните, как лет 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 я бы не советовал: ни производительности, ни гарантий он не добавляет.

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

Кодируем объект { 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); // безопасный энкодер в 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. 🧬