Не используйте строковые идентификаторы полей в формах (используйте локаторы)
Все реактовые библиотеки для работы с формами похожи друг на друга (и каждая несчастлива по-своему).
Вот, например, 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');
}
/* ... */
}Жаль, что нельзя защититься от чужих локаторов на уровне типов, но контексты вообще плохо поддаются строгой типизации.
Заключение
Однажды кто-то, кому менее лень, использует описанные принципы для создания по-настоящему типобезопасной библиотеки для работы с формами.