Не используйте строковые идентификаторы полей в формах (используйте локаторы)
Все реактовые библиотеки для работы с формами похожи друг на друга (и каждая несчастлива по-своему).
Вот, например, Formik:
<Formik initialValues={{ email: "" }} onSubmit={(values) => { /* ... */ }} > <Form> <Field name="email" as={EmailInput} /> </Form> </Formik>
Если мы опечатаемся в названии поля, мы не получим ошибки ни на уровне типов, ни в рантайме:
// опечатка! 👇 <Field name="notmail" as={EmailInput} />
Вместо этого колбэк onSubmit
получит неожиданные значения в качестве
аргумента.
Если кастомный комопнент EmailInput
имеет апи, не совместимый с формиком, мы также не получим ошибки ни на уровне типов, ни в рантайме — форма просто не будет работать:
function EmailInput(props: { value: string; onChange: (value: string) => void /* несовместимо с Formik! */; }) { /* ... */ }
Так просиходит, поскольку компонент Field
ничего не знает о схеме формы и о
типе поля email
в частности.
Посмотрим на более модный React Hook Form:
const { handleSubmit, control } = useForm({ defaultValues: { email: "" }, }); return ( <form onSubmit={handleSubmit((values) => { /* ... */ })} > <Controller name="email" control={control} render={({ field }) => <EmailInput {...field} />} /> </form> );
Дела обстоят лучше. Опечатка в названии поля будет обнаружена на уровне типов. Это благодаря хитрому типу FieldPath
, которым типизирован проп name
:
type T = FieldPath<{ users: { email: string }[] }>; // "users" | `users.${number}` | `users.${number}.email`
Совместимость кастомных контролов с библиотекой также частично гарантируется типами:
function EmailInput(props: { value: number /* неподходящий тип */; onChange: (value: string) => void; }) { /* ... */ } <Controller name="email" control={control} render={({ field }) => <EmailInput {...field} />} />; // Ошибка: // Types of property 'value' are incompatible. // Type 'string' is not assignable to type 'number'.
Но есть и недостатки. Во-первых, проверка совместимости только частичная:
несовместимая сигантура колбэка onChange
, например, не приведёт к ошибке. Это на совести авторов библиотеки и вполне может быть исправлено.
Во-вторых, вся магия держится на информации о схеме формы, содержащейся в типе control
. Если control
передаётся неявно через контекст, проверки не работают:
const methods = useForm({ defaultValues: { email: "" }, }); return ( <FormProvider methods={methods}> <form> {/* опечатка, но ошибки не будет */} <Controller name="notmail" render={({ field }) => <EmailInput {...field} />} /> </form> </FormProvider> );
В-третьих, типизация пропа name
затрудняет разработку переиспользуемых наборов полей. Например, мы делаем переиспользуемый компонент для редактирования адресов. Мы не знаем заранее, где именно в данных формы хранится адрес, поэтому «префикс» (путь к полю с адресом) передаётся пропом.
function AddressFieldset<T extends FieldValues>(props: { prefix: FieldPath<T>; control: Control<T>; }) { return ( <> <Controller control={props.control} name={`${props.prefix}.city`} render={() => { /* ... */ }} /> {/* ... */} </> ); } // Ошибка: // Type '`${Path<T>}.city`' is not assignable to type 'Path<T>'.
Ожидаемо, типы не позволяют нам добавить ".city"
к произвольному пути и
надеяться, что такое поле существует. Мы даже не можем быть уверены, чтоprefix
действительно указывает на адрес. Скорее всего, в таком случае нам
придётся отказаться от строгой типизации совсем:
function AddressFieldset(props: { prefix: string; control: Control }) { /* ... */ }
Далее я опишу новую сущность — «локатор». Использование локаторов вместо строк в качестве идентификатора поля в форме (то есть в качестве значения пропа name
) позволит реализовать безопасные, строго типизированные формы.
Локаторы
Локатор строится на основе схемы формы. Например:
type FormValues = { email: string; address: { city: string; street: string; }; };
Локатор для этой схемы будет выглядеть так:
const typeTag = Symbol("type"); const pathTag = Symbol("path"); const locator: { [typeTag]?: FormValues; [pathTag]: string; email: { [typeTag]?: FormValues["email"]; [pathTag]: string; }; address: { [typeTag]?: FormValues["address"]; [pathTag]: string; city: { [typeTag]?: string; [pathTag]: string; }; street: { [typeTag]?: string; [pathTag]: string; }; }; } = { [pathTag]: "", email: { [pathTag]: "email", }, address: { [pathTag]: "address", city: { [pathTag]: "address.city" }, street: { [pathTag]: "address.street" }, }, };
Здесь тип и реализация локатора описаны вручную. Разумеется, далее мы реализуем дженерик локатор для произвольного объекта.
Локатор имеет три важных свойства:
- Локатор имеет ту же структуру, что и исходный тип.
- Для каждого поля исходного типа локатор хранит информацию о пути этого поля.
- На уровне типов, для каждого поля исходного типа локатор хранит информацию о типе этого поля.
Опишем локатор как дженерик тип:
const typeTag = Symbol("type"); const pathTag = Symbol("path"); type Locator<T> = { [typeTag]?: T; [pathTag]: string; } & (T extends Record<string, unknown> ? { [K in keyof T]: Locator<T[K]> } : T extends (infer R)[] ? Locator<R>[] : {});
С точки зрения реализации, локатор — это билдер строк, в котором каждое обращение к свойству билдера добавляет название этого свойства к строке. Для реализации используем прокси:
function getLocator<T>(prefix: string = ""): Locator<T> { return new Proxy( {}, { get(target, key) { if (key === pathTag) { return prefix; } if (typeof key === "symbol") { throw new Error("Symbol path segments are not allowed in locator"); } const nextPrefix = prefix ? `${prefix}.${key}` : key; return getLocator(nextPrefix); }, } ) as Locator<T>; }
Эту реализацию можно оптимизировать, переиспользуя один и тот же перехватчик get
, вместо того чтобы создавать каждый раз новое замыкание:
const handlers: ProxyHandler<{ prefix: string }> = { get({ prefix }, key) { if (key === pathTag) { return prefix; } if (typeof key === "symbol") { throw new Error("Symbol path segments are not allowed in locator"); } const nextPrefix = prefix ? `${prefix}.${key}` : key; return new Proxy({ prefix: nextPrefix }, handlers); }, }; const rootLocator = new Proxy({ prefix: "" }, handlers); function getLocator<T>(): Locator<T> { return rootLocator as unknown as Locator<T>; }
Использование локатора в форме
Апи нашей гипотетической библиотеки для работы с формами упрощённо может
выглядеть так:
function useForm<T>(options: { initialValues: T }): { n: Locator<T> } { return { n: getLocator<T>(), /* ... */ }; } function Field<V>(props: { name: Locator<V>; render: (field: { name: string; value: V; onChange: (value: V) => void; }) => ReactElement; }) { return props.render({ name: props.name[pathTag], /* ... */ }); }
Пользователь библиотеки получает корневой локатор из хука useForm
и использует его в качестве имени поля:
const { n } = useForm({ initialValues: { users: [ { email: "", address: { city: "", street: "", }, }, ], }, }); <Field name={n.users[0].email} render={(field) => <EmailInput {...field} />} />;
Локатор не позволит создать имя, которого не существует в форме. Типы для пропа render
не позволят использовать компонент, пропы которого не совместимы с библиотекой.
Типизировать переиспользуемые наборы полей тоже стало проще:
function AddressFieldset(props: { prefix: Locator<{ city: string; street: string }>; }) { return ( <> <Field name={props.prefix.city} render={() => { /* ... */ }} /> {/* ... */} </> ); } <AddressFieldset prefix={n.users[0].address} />
Этот компонент требует, чтобы ему передали локатор, указывающий именно на адрес, и поэтому может безопасно обращаться к полям адреса через переданный локатор. И, поскольку локатор повторяет структуру исходного объекта, вместо локатора адреса можно передать локатор любого объекта, совместимого с адресом по присваиванию.
И, кстати, рефакторинг на названиях полей теперь работает.
Мы не должны разрешать использовать локатор одной формы для полей другой формы.
const { form, n } = useForm({ initialValues: { email: '' } }); const { form: anotherForm, n: anotherN } = useForm({ initialValues: { notemail: '' } }); <FormProvider form={anotherForm}> {/* плохо: в anotherForm нет email */} <Field name={n.email} render={() => { /* ... */ }} /> </FormProvider>
Защититься от использования чужого локатора мы можем только в рантайме. Подход такой: пометим саму форму и её корневой локатор уникальным значением — «брендом»; производные локаторы наследуют бренд от своего родителя; компонент Field
проверяет совпадение бренда формы и бренда локатора.
function useForm<T>(options: { initialValues: T }) { const [brand] = useState(() => Symbol('formBrand')); return { // локатор n и все производные от него будут помечены // брендом, уникальным для этой формы n: getLocator<T>(brand), // тот же самый бренд будет и в контексте формы form: { brand, /* ... */ }, }; } function Field<V>(props: { name: Locator<V>; /* ... */ }) { const { brand } = useContext(FormContext); // сверям бренд из контекста формы и бренд локатора if (brand !== name[brandTag]) { throw new Error('Foreign locator used to address form field'); } /* ... */ }
Жаль, что нельзя защититься от чужих локаторов на уровне типов, но контексты вообще плохо поддаются строгой типизации.
Заключение
Однажды кто-то, кому менее лень, использует описанные принципы для создания по-настоящему типобезопасной библиотеки для работы с формами.