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.