Как я превратил хаотичные формы во Vue в типизированную модель данных (AdaptForm)

от автора

История о том, как задача «подсветить обязательные поля» превратилась в полноценную 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 });}

Что здесь происходит:

  1. Form — базовый класс, который собирает все поля

  2. Field — объект поля, принимает класс типа, значение по умолчанию и опции

  3. 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';

Как это работает:

  1. В package.json указан subpath export ./luxon

  2. Luxon в peerDependencies с пометкой optional: true

  3. При сборке 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)

  • Больше плагинов из коробки

Выводы

  1. Не бойтесь переписывать. Первая версия на JavaScript была рабочей, но тупиковой. Вторая на TypeScript — мёртвой. Третья — живой и растущей.

  2. Берите лучшие идеи. Django дал концепцию полей, Pydantic — элегантность описания, Luxon — работу с датами.

  3. Думайте о пользователе. Плагины и маски появились не потому, что «это круто», а потому что реально нужно на фронтенде.

  4. Публикуйте раньше. Я год держал библиотеку в столе. А можно было выложить раньше и получать фидбек.

  5. Используйте то, что создаёте. Если вы сами не пользуетесь своей библиотекой — никто не будет.

Попробуйте AdaptForm в своём проекте:

npm install adaptform

GitHub ⭐ | npm 📦 | Документация 📖

Буду рад фидбеку, звёздам и конструктивной критике! 🚀


P.S. Если у вас есть идеи по улучшению или вы нашли баг — создавайте issue на GitHub. Я отвечаю в течение 24 часов.

ссылка на оригинал статьи https://habr.com/ru/articles/1044380/