Я выпустил библиотеку banditypes — самый маленький валидатор схем для TS / JS. Удивительно, но базовый функционал валидации с приятным API можно упихнуть в 400 байт, если сконцентрироваться на размере и добавить пару грязных хаков. В этой статье расскажу, как добился такого результата.
Но для начала, если вы еще не знакомы с проблемой и популярными решениями (zod, superstruct, yup старина joi) — пара слов про валидацию данных вообще. Мы собираемся работать с данными какой-то формы — скажем, данные о товаре, то есть объект со строкой в поле title
и числом в поле price
. Но получаем мы эти данные из ненадёжного источника — localStorage
(как раз эту задачу я и решал), внешнего АПИ или вообще от пользователя. Если мы просто берем эти данные и используем как наш объект, а там вдруг что-то не то (например, массив строк), появляются смешные баги с «[object Object] прибудет через undefined минут». Гораздо лучше проверить «форму» данных один раз, при чтении, и показать явную ошибку.
На голом TypeScript (и на JavaScript тоже) описывать эту логику больно:
if (typeof res === 'object' && res
&& 'title' in res && typeof res.title === 'string'
&& 'price' in res && typeof res.price === 'number'
) {
return res
}
Гораздо приятнее писать декларативные валидации на специальном DSL. Все библиотеки сошлись примерно к одному варианту:
const userSchema = object({
title: string(),
price: number(),
});
const user = userSchema(res);
Современные валидаторы умеют генерировать TS-тип прямо из схемы через Infer<typeof userSchema>
, чтобы разработчик как обезьянка не писал еще раз то же самое в другом синтаксисе. Но об этом — в другой раз.
Так вот, banditypes решает эту задачу с 400 байт вместо 11КБ zod и yup или 1.8Кб superstruct. Я не какой-то маньяк, 1.8КБ — уже очень хорошо, но я любопытный и люблю челлендж, и я смог. Как мне это удалось? Сейчас расскажу!
Как правильно измерять размер библиотек
Когда кто-то говорит, что «библиотека X весит 40 килобайт», обычно имеют в виду, что 40 килобайт — это полный umd-бандл библиотеки со всеми функциями, которые в ней есть. Это было логично в 2008, когда мы вставляли на сайт jQuery с сомнительного CDN, это было куда ни шло в 2015, когда мы реквайрили монолитный шматок кода и радовались, но в 2023, когда статические импорты бороздят просторы ес-модулей, это уже не говорит ни о чём вообще.
Во-первых есть три-шейкинг. Если в библиотеке 100 функций, а я использую одну, то по-хорошему и в бандл ко мне должна залезть только эта функция. Замер полного размера «наказывает» библиотеки, в которых много функций (ты не сильный, а ТОЛСТЫЙ) и не говорит ничего о том, как хорошо библиотека три-шейкается. Понятно, что это верхняя граница на размер, но способ измерить сборку с разными подмножествами функциональности нужен.
Чем меньше библиотека, тем менее реалистичный размер даёт классический метод. В umd-бандле (или любой standalone-сборке) библиотеки без клиентского кода есть как минимум три вещи, которых не будет в финальном бандле приложения:
Имена экспортируемых функций (не смейтесь, это около 25% banditypes). Любой нормальный бандлер как минимум смэнглит имена экспортов в какой-нибудь
qi=()=>...
, а то и вообще заинлайнит их в место использования.Повторы стандартного JS-синтаксиса (
const, function, Object, catch
), который и так присутствует в клиентском коде, и, слава gzip, в библиотеке их можно использовать почти бесплатно.22 байта End of Central Directory record, который есть в любом gzip-файле, и второй раз от вашей библиотеки не появится.
Поэтому я измеряю размер оригинальным, но логичным способом — беру небольшое «тестовое приложение» (совсем крошечное, только чтобы гзип прогреть), и собираю его 2 раза — один с подключенной валидацией, один — без. Разницу размеров этих сборок я и называю как размер библиотеки. Тестовых приложений можно сделать несколько — я измеряю варианты со всеми валидаторами (385 байт); с самыми частыми (примитивы + объект + массив, 206 байт) и ядро без встроенных валидаторов (96 байт).
Чтобы вы не подумали, что я упростил себе задачу (ишь, EOCD считать не хочет!) — при таком способе в размер входит еще и код для интеграции библиотеки в клиентский код, то есть описание схемы. Думаю, это честно — мы же оптимизируем размер всего приложения, а не только библиотеки? Иначе я мог бы вообще не писать ничего и назвать это «невероятной 0-байтной библиотекой для валидации: все валидации вы пишете сами».
Дизайн для маленькости
Если отчаянно шлёпать по клавиатуре, а потом попытаться волшебным образом ужать получившийся бандл, ничего хорошего не выйдет.
Режем скоуп. Хорошие библиотеки валидации возвращают ошибки с приятным сообщением вида
{ message: 'Expected string, got number', path: ['item', 'title'] }
, и не по одной, а все сразу. Такую библиотеку можно использовать для взаимодействия с пользователем — например, при проверке форм. От этой чудесной фичи я сразу же отказался.Меньше методов — лучше три-шейкинг. Почему superstruct три-шейкается, а zod — нет? Потому что в superstruct всё сделано функциями:
min(number(), 9000)
, а в zod — методами:z.number().gt(5)
. Минификатор может посмотреть на код и сказать «ха, функция min не используется, удалю-ка её». А вот найти все места, где используются объекты Number, увидеть, что их метод gt не используется, и удалить его, почти невозможно. К тому же у min гораздо проще ужать название в какой-нибудь w. Поэтому предпочитаем функциональные АПИ.-
Больше расширяемости. Если мы убрали функции из библиотеки, но при этом не хотим загонять пользователей (и себя) в тупик недостатком фичей, нужно дать им возможность дописывать нужную функциональность самим. На каждый валидатор добавили два метода:
type1.map(res => ...)
— дополнительно провалидировать данныеstring().map(str => str.length ? str : fail())
или преобразовать их:string().map(str => [str])
type1.or(val => ...)
— если левая валидация не прошла, попробовать правую. Кроме очевидных юнион-типов (в том числе — опциональных,string().or(optional())
), можно реализовать дефолтные значения:string().or(() => 'default')
В итоге получилось красивое (и совместимое с популярными библиотеками в базовых сценариях) API:
const userSchema = object({
title: string(),
// дополнительная валидация
price: number().map(num => num > 0 ? num : fail()),
tags: set(enums(['sale', 'liked', 'sold']))
});
// строка или массив строк
const strings = string().or(array(string()));
// преобразование данных
const arraySum = array()
.map(arr => arr.reduce((acc, e) => acc + e, 0));
Грязные хаки
Заложив маленькость как основное ограничение на этапе дизайна, можно уже добиться неплохого результата — в моём случае, около 550 байт. Теперь настаёт время хаков — пытаемся сделать всё то же самое, но ещё компактнее, при этом не сильно испортить DX. Пять оптимизаций помогли мне ужать размер до невероятных 385 байт:
Используй современный JS (не уверен, что это можно назвать грязным трюком). На удивление, замена
function array(raw) {}
наconst array = (raw) => {}
и использование спредов уменьшило сразу на 23 байта (да, на моих масштабах это праздник). gzip достаточно хорошо жмёт всеfunction
(несжатый бандл уменьшился на 430 байт), но выгода всё таки есть.Уменьшай API. Зачем нужен тип
literal(42)
, еслиenums([42])
делает то же самое? 16 байт.Повторяй. Если начал писать функции стрелками, то пиши стрелками все функции, так лучше жмётся. Если в коде используется слово
Object
, то следующиеObject
мы получем почти бесплатно, спасибо gzip. На удивление, правильное переиспользование функций при gzip занимает больше места, чем копипаст с небольшим изменением. И да, если все-все функции принимают 1 аргумент, они жмутся лучше, потому что повторяющийся кусок длиннее.Трюк, которым я горжусь: вместо серии
raw => typeof raw === 'string' ? raw : fail()
я придумал типlike = tag => raw => typeof raw === typeof tag ? raw : fail()
. То есть мы передаём значение-пример, какstring = like('')
, и сравниваем typeof примера с переданным значением. 20 байт!Зачем делать
throw new TypeError('Invalid Banditype')
, если можно просто вызвать строку,'bad banditype'()
, и ошибка всё равно вылетит? Да, туда нельзя передать кастомное сообщение и прочитать его сверху, но вот такие у нас ограничения. 20 байт.
Я попробовал много идей, которые не сработали или сделали хуже. Замена throw
на return null
или вариации return true / false
немного уменьшает размер, но усложняет валидацию настоящих null
и усложняет координацию проверок в коллекциях (теперь нужно руками проверять валидацию на каждом элементе, она не вылетит вверх по волшебству). Перекладывание for..in
в Object.keys
увеличивает размер, какие бы вариации я не пробовал.
Наконец, от одной хорошей оптимизации я сознательно отказался: это замена методов-комбинаторов map / or
на чистые функции. Вариант с функциями лучше три-шейкается и позволяет минифицировать названия функций (e
вместо .map
), но, на мой вкус, ухудшает API: or(map(string(), s => [s]), array(string))
читается хуже, чем string().map(s => [s]).or(array(string()))
, потому что все слова в естественном порядке. К тому же методы проще типизировать.
Сегодня мы познакомились с banditypes — самой маленькой JS-библиотекой для валидации. Упихнуть ее в 400 байт я смог, используя три дизайн-принципа
Откажись от лишних фич (подробных сообщений об ошибках валидации)
Используй функции, а не методы, потому что они лучше минифицируются.
Поддержи кастомизацию, чтобы пользователь мог сам добавить нужный, недостающий функционал.
И пять оптимизаций:
Современный JS — меньше кода
Удаление дублирующихся API
Повторяющийся код — хороший код для gzip
typeof raw === typeof sample
Вместо
throw
вызываем строку:'bad banditype'()
Заодно придумал оригинальный способ измерения размера библиотеки на основе тестового приложения — это гораздо честнее, чем классический размер UMD-бандла.
Используйте на здоровье, не забывайте поставить звёздочку на гитахбе — это поднимает мне настроение. Если было интересно — подписывайтесь на мой телеграм-канал, там маленький и увлекательный контент.
Комментарии (16)
farwayer
00.00.0000 00:00+1Круто! Мне нравятся маленькие библиотеки. Но
map
это плохое название для метода. Не то, что в js подmap
подразумевается.Заодно придумал оригинальный способ измерения размера библиотеки на основе тестового приложения
thoughtspile Автор
00.00.0000 00:00Спасибо!
Мне второй раз предъявляют за
map
, но мне нравится это название — отображение же! (возможно, конформные отображения сломали мне мозг) В голом JS под map подразумевается не только то, что вы подумали, но еще и ассоциативный массивMap
. В TS есть mapped types, который совсем не про массивы. В rx есть map, и ничего, как-то живут. В общем, если подойти достаточно абстрактно, всё есть map)
size-limit конечно лучше чем жать голый UMD, но мы всё ещё измеряем один бандл, а не разницу с пустым приложением, так что EOCD и непрогретый gzip остаются. К тому же завязка на вебпак, который инжектит свой рантайм — я что-то уже не хочу к нему приближаться вообщеfarwayer
00.00.0000 00:00+2Все же Map и mapped types это совсем другое, чем вызов метода. С
map
в rxjs как раз все хорошо: это обработка элемента последовательности. А вот по примеру из readme библиотекиarray(number()).map(...
я бы никогда не подумал, что в аргумет map уйдет массив, а не элемент. Это плохой UX, будет сбивать тех, кто не знаком с библиотекой. Прям очень плохой.
Советую поменять до 1.0.0 версии.К тому же завязка на вебпак,
Там в `preset-small-lib` используется
esbuild
thoughtspile Автор
00.00.0000 00:00Я обдумаю это предложение, спасибо)
> Там вpreset-small-lib
используетсяesbuild
Благая весть! Вот я дед
aleksandr-s-zelenin
00.00.0000 00:00+1При чём здесь какая-то схема? Вот любят нынче понапихать умных слов в самые обыденные вещи. Смотрю заголовок, думаю: что ещё за валидация схем такая? А это оказывается просто валидация данных... или я что-то не понимаю? )
thoughtspile Автор
00.00.0000 00:00Я ждал этого комментария) "Просто валидация" — растяжимое понятие, хотел дополнительно уточнить что речь не о валидации форм
baturinsky
00.00.0000 00:00+1Чтобы прокачать маленькость в js можно посмотреть трюки, которые авторы https://js1024.fun/ используют. Или самому поучаствовать и в дискорде ихнем пообщаться.
polRk
00.00.0000 00:00Эло это ваш con fn = () =>. Понимаю использовать в как временную функцию, но не как название экспортируемой функции. Потом весь код в стактоейсе состоит из anonymous…
Я, пожалуй, предпочту библиотеку где функции создают правильно.
thoughtspile Автор
00.00.0000 00:00+1В спецификации со времён ES6 есть специальное поручение (12.14.4.1.e.iii) в такой ситуации доставать
name
функции из имени переменной (и из многих других мест, ищется поSetFunctionName
).И это легко проверить
А в минифицированном коде имена и функций, и переменных, обфусцируются и ориентироваться в любом случае придется по
line:column
Предпочитать любые библиотеки по любому критерию, разумеется, базовое право каждого человека.
iliazeus
Как "спортивный" проект для уменьшения размера — выглядит очень неплохо. Жалко только, что достигается это ухудшением developer experience:
И, потенциально, за счет ухудшения возможностей для оптимизатора:
thoughtspile Автор
А я честно скажу, что сделал проект вдогонку к BanditStash — библиотеке для работы с localStorage. Для кейса "проверить, не битые ли данные в сторадже" детальной ошибки не нужно, просто игнорируй битые данные. Для валидации форм или (тем более) параметров АПИ, конечно, нужна библиотека с нормальным фидбеком. Каждой проблеме — своё решение.
Тип literal удалил из двух соображений — во-первых, на практике редко подходит только одно значение (зачем его вообще хранить)? Во-вторых, всегда можно создать пользовательский тип
literal = <T extends string>(v: T) => enums([v]);
На бенчмарках banditypes — одна из самых быстрых библиотек без кодогенерации, так что аргумент про оптимизатор не принимается) К тому же валидация — вряд ли невероятно горячий код, так что по перформансу я не заморачивался.
iliazeus
Разумно. Для заявленной задачи - хорошее решение.
farwayer
Вы недооцениваете оптимизатор ;) В
like()
уйдет константа, а значитtypeof tag
тоже станет константой. Оптимизатор легко с таким справляется.