March 6, 2023

Не используйте строковые идентификаторы полей в формах (используйте локаторы)

Все реактовые библиотеки для работы с формами похожи друг на друга (и каждая несчастлива по-своему).

Вот, например, 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" },
  },
};

Здесь тип и реализация локатора описаны вручную. Разумеется, далее мы реализуем дженерик локатор для произвольного объекта.

Локатор имеет три важных свойства:

  1. Локатор имеет ту же структуру, что и исходный тип.
  2. Для каждого поля исходного типа локатор хранит информацию о пути этого поля.
  3. На уровне типов, для каждого поля исходного типа локатор хранит информацию о типе этого поля.

Опишем локатор как дженерик тип:

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');
  }

  /* ... */
}

Жаль, что нельзя защититься от чужих локаторов на уровне типов, но контексты вообще плохо поддаются строгой типизации.

Заключение

Однажды кто-то, кому менее лень, использует описанные принципы для создания по-настоящему типобезопасной библиотеки для работы с формами.