Я выпустил библиотеку 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'
) {
retrun 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-бандла.
Используйте на здоровье, не забывайте поставить звёздочку на гитахбе — это поднимает мне настроение. Если было интересно — подписывайтесь на мой телеграм-канал, там маленький и увлекательный контент.
Автор: Владимир Клепов