Раздел 2 · Программирование / CS

Higher-Kinded Data: одна модель, много форм

lead~35 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Higher-Kinded Data: одна модель, много форм

В уроке 07 мы выучили URI-трюк: способ передавать конструктор типа как параметр функции. Это позволило записать Functor, Applicative, Monad универсально, для любого контейнера. Сегодня тот же трюк применим в другую сторону: параметризуем не функцию, а сам record-тип. Один record User<F>, и мы получаем из него четыре формы данных одной структурой.

Это и есть Higher-Kinded Data, HKD: паттерн от Sandy Maguire, заметка которого 2018 года стала канонической. После урока ты прочитаешь без шифровки оригинальные посты Maguire, поймёшь, почему @effect/schema так быстро строит лензы, и научишься делать формы регистрации без дублирования типов.

Боль четырёх параллельных моделей

Возьмём типичный сценарий: форма регистрации. По ходу жизни той же сущности нам нужны разные представления:

// 1. Сырой ввод от пользователя: всё string, всё опционально
type UserInput = {
  name?: string;
  email?: string;
  age?: string;
};

// 2. После парсинга: типы правильные, но всё ещё опционально
type UserPartial = {
  name?: string;
  email?: string;
  age?: number;
};

// 3. После валидации: список ошибок на каждое поле
type UserErrors = {
  name?: string;
  email?: string;
  age?: string;
};

// 4. Готовый, валидный объект
type User = {
  name: string;
  email: string;
  age: number;
};

Четыре типа, четыре копии списка полей. Если добавить пятое поле, дублирование увеличивается на четыре строки. Если кто-то забудет обновить одну из копий, типы расходятся, баг тихий.

Кодгены вроде zod решают проблему частично через z.infer<typeof schema> и z.infer<typeof schema.partial()>. Это работает, но привязано к runtime-валидаторам. HKD даёт чистое решение на уровне типа, без какой-либо runtime-инфраструктуры.

HKD: параметризовать record над конструктором типа

Идея: вместо четырёх параллельных типов берём один, у которого над полями стоит дополнительный параметр F:

type User<F> = {
  readonly name: F<string>;
  readonly email: F<string>;
  readonly age: F<number>;
};

F это конструктор типа: Identity, Option, Array, Validation, что угодно kind * -> *. Подставляя разные F, получаем разные формы одной модели:

type Identity<A> = A;
type Option<A> = { readonly kind: 'some'; readonly value: A } | { readonly kind: 'none' };
type Const<E, A> = { readonly errors: readonly E[] }; // ignores A
type Validation<E, A> =
  | { readonly kind: 'valid'; readonly value: A }
  | { readonly kind: 'invalid'; readonly errors: readonly E[] };
  • User<Identity> это полный валидный объект (Identity это no-op, каждое поле своего исходного типа).
  • User<Option> это частичный объект, где каждое поле может быть присутствовать или отсутствовать.
  • User<(a: any) => string | undefined> (если бы TS принимал такие параметры) это карта валидаторов: каждое поле умеет себя проверять.
  • User<(a: any) => readonly string[]> это карта ошибок.

К сожалению, в TypeScript конструктор типа не передаётся напрямую. Нужно делать ровно то, что делал fp-ts с Functor: использовать URI-трюк и таблицу URItoKind.

URI-трюк, второй виток

Напомню: в уроке 07 мы определяли таблицу URI -> конструктор:

interface URItoKind<A> {
  readonly Identity: A;
  readonly Option: Option<A>;
  readonly Array: readonly A[];
}

type URI = keyof URItoKind<unknown>;
type Kind<F extends URI, A> = URItoKind<A>[F];

Тогда HKD-record выглядит так:

interface UserHKD<F extends URI> {
  readonly name: Kind<F, string>;
  readonly email: Kind<F, string>;
  readonly age: Kind<F, number>;
}

type User = UserHKD<'Identity'>;
type UserPartial = UserHKD<'Option'>;
type UserList = UserHKD<'Array'>;

Теперь у нас один HKD-тип UserHKD и три его специализации. Если добавить поле phone: Kind<F, string>, оно автоматически появится во всех четырёх формах. Дублирования нет.

Расширение через module augmentation:

declare module './hkt' {
  interface URItoKind<A> {
    readonly Validation: Validation<string, A>;
  }
}

type UserValidation = UserHKD<'Validation'>;
// { name: Validation<string, string>; ... }

UserValidation это карта валидационных результатов: на каждое поле либо valid со значением, либо invalid со списком ошибок.

Generic операции над HKD

Когда у нас есть HKD-структура, можно описать map, traverse и sequence для всего record-а сразу.

mapHKD принимает преобразование Kind<F, A> -> Kind<G, A> и применяет его ко всем полям:

const mapHKD =
  <F extends URI, G extends URI>(transform: <A>(fa: Kind<F, A>) => Kind<G, A>) =>
  <T extends Record<string, unknown>>(record: T): { [K in keyof T]: Kind<G, T[K] extends Kind<F, infer A> ? A : never> } => {
    const out = {} as Record<string, unknown>;
    for (const key of Object.keys(record)) {
      out[key] = transform(record[key] as Kind<F, unknown>);
    }
    return out as never;
  };

traverseHKD интереснее: он “переставляет слои”. Если у нас UserHKD<'Validation'> (на каждое поле либо ошибка, либо значение), мы хотим получить Validation<UserHKD<'Identity'>> (либо все ошибки, либо весь объект):

const traverseUserValidation = (
  user: UserHKD<'Validation'>,
): Validation<string, UserHKD<'Identity'>> => {
  const allErrors: string[] = [];
  const ok: Partial<UserHKD<'Identity'>> = {};

  for (const key of Object.keys(user) as (keyof UserHKD<'Validation'>)[]) {
    const v = user[key];
    if (v.kind === 'invalid') {
      allErrors.push(...v.errors.map((e) => `${String(key)}: ${e}`));
    } else {
      (ok as Record<string, unknown>)[key as string] = v.value;
    }
  }

  return allErrors.length > 0
    ? { kind: 'invalid', errors: allErrors }
    : { kind: 'valid', value: ok as UserHKD<'Identity'> };
};

Сравни с тем же traverse-комбинатором из урока 07: семантика та же, только обходим record-поля, а не элементы списка. И параметризуется тот же Applicative-инстанс: с Either это fail-fast (вылетаем на первой ошибке), с Validation это накопление.

sequenceHKD это частный случай traverse с тождественной функцией:

const sequenceHKD = traverseHKD((x) => x);
// UserHKD<'Validation'> -> Validation<UserHKD<'Identity'>>

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.