История о том, как задача «подсветить обязательные поля» превратилась в полноценную TypeScript-библиотеку с 500+ скачиваниями в неделю.
Введение
На одном проекте нам необходимо было использовать много форм для ввода данных от пользователей. Каждая форма собиралась отдельно, максимум что мы использовали — это миксины для валидации данных и всё. Но при этом у каждого поля в таблице было несколько источников истины и описывать ошибки или добавлять стилизацию было отдельным гемором. Пусть это и было не очень удобно, но работало… Ровно до того момента как нам дали задачу — подсветить поля, которые необходимы для заполнения. Не знаю почему мы просто не добавили звёздочки к этом полям, но, как оказалось, это было к лучшему.
Я начал думать как это реализовать максимально удобно, ведь форма на сайте, это не только валидация, это ещё и проверка состояния формы, преобразование данных, отображение ошибок и взаимодействие между полями.
Тогда и родилась идея: создать класс формы, который сам знает о своих полях, сам проверяет их, сам отдаёт ошибки, может преобразовать вводимые данные в нужный для отдачи на сервер вид и может автоматически заполнить поля формы. Так появилась первая версия библиотеки, которую я позже назвал AdaptForm.
Версия 1: JavaScript, Django-style и первые грабли
Так как я Fullstack-разработчик и мой основной язык это питон, то за основу форм я взял формы Django. Мне показалось, что они максимально подходили под то что мне было нужно.
class BalanceForm(forms.Form): amount = forms.DecimalField(label='Сумма пополнения', min_value=0.01, max_digits=12, decimal_places=2)
Первая версия AdaptForm выглядела похоже:
const form = new Form({ phone: new PhoneField(), login: new LoginField(), email: new EmailField(), name: new NameField(), surname: new NameField(), gender: new SelectField(), birthdate: new DateField(), phone_friend: new PhoneField().kwargs({is_required: false}), from_where_category_id: new SelectField(),}),
Библиотека решала задачу: поля формы сами знали как валидировать данные, в каком виде отправлять на сервер и какие ошибки допустил пользователь при вводе данных, а форма просто собирала все эти поля в кучу и могла массово проводить проверки и получать данные для отправки на сервер.
Но было ощущение, что чего-то не хватает. Постоянно нужно было дописывать, менять функционал, так как не всегда было удобно использовать то что есть, а расширение превращалось в ад.
Так или иначе, библиотека работала и я начал использовать её в других проектах. Но было не удобно переносить весь код библиотеки в ручную, и я решил залить её на npm, чтобы пользоваться самому и вдруг кому ещё понадобится.
Провальная попытка: TypeScript, но непонимание сути библиотеки
Перед публикацией я решил доработать библиотеку, перенеся её на TypeScript.
Получилось плохо. Я не понимал что хочу от библиотеки. Начал смотреть на аналоги, например Zod, и совершенно потерялся. Я не понимал что хочу от библиотеки, начал экспериментировать, добавлять возможность указывать свои валидаторы и прочее, но выходило не понятно что. Не было своей идентичности.
Я запушил версию в npm, но сам ей не пользовался. Максимум — один раз установил в одном проекте, посмотрел и закрыл. Библиотека лежала мёртвым грузом.
Переосмысление: Pydantic, аннотации и классы
Спустя время я снова вернулся к задаче. У меня появилось свободное время и я решил, что пора уже доделать этот проект. В какой-то момент мне пришла в голову мысль, что Pydantic работает с формами максимально красиво и элегантно и я
from pydantic import BaseModelclass UpdateProfileForm(BaseModel): public_id: UUID | None = None code: Annotated[str, Field(min_length=4, max_length=10)] | None = None phone: PhoneStr city: Annotated[str, Field(min_length=1, max_length=100)] club_name: Annotated[str, Field(min_length=1, max_length=100)] | None = None name: Annotated[str, Field(min_length=1, max_length=50)] surname: Annotated[str, Field(min_length=1, max_length=50)] birthdate: Date email: Email login: Login account_type: UserAccountType
Но в JavaScript/TypeScript нет такой магии с аннотациями типов. Нельзя написать:
class User { name: string; // Это просто тип, он стирается в рантайме}
Нужно было другое решение. Я как раз перешёл на новую версию pydentic и увидел что, теперь поля формы рекомендуется оформлять через Annotated, указывая тип, к которому нужно привести данные, и класс поля, которое уже включает в себя всю валидацию с возможностью добавления дополнительных проверок.
Новая архитектура: Field + TypeClass
Так как в JavaScript/TypeScript нет возможности задать поля таких образом, или я об этом не знаю, я решил разделить понятия «поле» и «тип данных»:
-
Field — контейнер, который хранит значение, ошибки, плагины
-
TypeClass — стратегия валидации и приведения типов
TypeConstructor: FieldTypeConstructor<T, O>, defaultValue?: T | null, options?: O | null
При создании поля ты передаёшь класс типа первым аргументом, после этого указываешь стартовое значение поля, опционально, а после этого указываешь опции, которые уже зависят от TypeConstructor :
import { Form, Field, StringType, NumberType } from 'adaptform';class UserForm extends Form { name = new Field(StringType, null, { maxLength: 100 }); email = new Field(StringType, null, { pattern: /^[^@]+@[^@.]+[.][^@.]+$/ }); age = new Field(NumberType, null, { gt: 0, lt: 120 });}
Что здесь происходит:
-
Form — базовый класс, который собирает все поля
-
Field — объект поля, принимает класс типа, значение по умолчанию и опции
-
StringType, NumberType — классы, которые реализуют методы
cast()(приведение) иvalidate()(проверка)
TypeScript автоматически выводит типы всех полей
const form = new UserForm();// Тип выводится автоматически:type Fields = typeof form.fieldsValue;// { name: string | null; email: string | null; age: number | null }
Никаких дженериков! Вся магия в том, что TypeScript берёт типы из конструктора Field<T, O> и подставляет их.
Почему это удобно: расширяемость
Главным преимуществом данного подхода я отметил расширяемость. Чтобы создать свой тип данных, достаточно просто наследовать класс от FieldType и реализовать три метода:
-
cast — метод приведения данных к нужному типу
-
validate — проверка валидности вводимых данных
-
getTypeName — описание типа данных, это уже больше для себя
import { FieldType } from 'adaptform';class PhoneType extends FieldType<string> { cast(rawValue: any): string | null { if (typeof rawValue === 'string') { return rawValue.replace(/[^\d+]/g, ''); } return null; } validate(value: string): string[] { const errors: string[] = []; if (!/^\+7\d{10}$/.test(value)) { errors.push('Неверный формат телефона'); } return errors; } getTypeName(): string { return 'телефон'; }}// Использованиеclass ContactForm extends Form { phone = new Field(PhoneType, null);}
Плагины: маски и не только
Форма на фронтенде отличается от серверной валидации тем, что пользователь видит данные. Ему нужно показывать форматированный ввод: маски телефона, разделители в номере карты, автозамену символов.
Хоть Pydantic и вдохновил билиотеку, но всё-таки формы на сервере и фронте отличаются. Для последних характерно наличие визуального оформления данных. Для этого нужно уметь форматировать ввод данных.
Для этого я добавил систему плагинов:
import { Field, StringType, MaskPlugin } from 'adaptform';const phone = new Field(StringType, null, { plugins: [ new MaskPlugin({ maskFormat: '/+/7 (___) ___-__-__', maskPlaceholder: '_', digitPattern: /\d/ }) ]});phone.rawValue = '9991234567';console.log(phone.rawValue); // '+7 (999) 123-45-67'console.log(phone.valueClear); // '+79991234567'
Плагин получает доступ к полю на этапах:
-
init()— инициализация -
toRawValue()— форматирование перед показом пользователю -
toValueClear()— очистка маски перед валидацией
Это позволяет создавать сложную логику отображения, не трогая ядро библиотеки.
Luxon: опциональная зависимость
Для работы с датами я выбрал Luxon. Но тащить опциональную зависимость в билиотеку не очень хотелось, поэтому я сделал отдельную точку входа для него:
// Без Luxon — лёгкий импортimport { Form, Field, StringType } from 'adaptform';// С Luxon — когда нужноimport { DateLuxonType } from 'adaptform/luxon';
Как это работает:
-
В
package.jsonуказан subpath export./luxon -
Luxon в
peerDependenciesс пометкойoptional: true -
При сборке tsup создаёт отдельные бандлы:
index.mjs(без Luxon) иluxon.mjs(с Luxon)
Пользователь, которому не нужны даты, не получает их.
Результаты
После публикации версии 2.0.0:
-
592 скачивания в неделю (и это только начало)
-
Bundle size: 5 КБ gzipped (ядро)
-
100/100 Supply Chain Security на Socket.dev
-
90/100 Quality на Socket.dev
-
Полная типизация без единого ручного дженерика
-
Плагинная система, которую можно расширять бесконечно
Но самое главное — я понял в чём заключается идентичность библиотеки, это не просто билиотека валидации форм, а именно типизрованная модель данных форм на фронтенде.
Что дальше?
Планы на развитие:
-
Асинхронная валидация (запросы к API)
-
Больше плагинов из коробки
Выводы
-
Не бойтесь переписывать. Первая версия на JavaScript была рабочей, но тупиковой. Вторая на TypeScript — мёртвой. Третья — живой и растущей.
-
Берите лучшие идеи. Django дал концепцию полей, Pydantic — элегантность описания, Luxon — работу с датами.
-
Думайте о пользователе. Плагины и маски появились не потому, что «это круто», а потому что реально нужно на фронтенде.
-
Публикуйте раньше. Я год держал библиотеку в столе. А можно было выложить раньше и получать фидбек.
-
Используйте то, что создаёте. Если вы сами не пользуетесь своей библиотекой — никто не будет.
Попробуйте AdaptForm в своём проекте:
npm install adaptform
GitHub ⭐ | npm 📦 | Документация 📖
Буду рад фидбеку, звёздам и конструктивной критике! 🚀
P.S. Если у вас есть идеи по улучшению или вы нашли баг — создавайте issue на GitHub. Я отвечаю в течение 24 часов.
ссылка на оригинал статьи https://habr.com/ru/articles/1044380/