Почему Banditypes — самая маленькая TS-библиотека для валидации схем
Я выпустил библиотеку 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-бандла.
Используйте на здоровье, не забывайте поставить звёздочку на гитахбе — это поднимает мне настроение. Если было интересно — подписывайтесь на мой телеграм-канал, там маленький и увлекательный контент.