Обложка: YAM#3: один TypeScript, что правит всеми

YAM#3: один TypeScript, что правит всеми

В прошлых статьях мы с вами написали простейшую мидлварь для redux, а потом добавили в неё всяких фич, которые могут быть очень полезны в реальных приложениях. Чтобы сделать её ещё более удобной, сегодня мы займёмся типизацией yam. В отличие от предыдущих статей, здесь я не буду воспроизводить все страдания, ибо даже я сам не смогу полностью описать все лабиринты пещер, в которых плутал. Я просто постараюсь последовательно рассказать, как можно типизировать такую библиотеку, зная конечный результат. Конечно, упоминая проблемы, на которые я натыкался по ходу, чтобы обосновать принятые решения.

Мир изменился. Я чувствую это в воде. Чувствую в земле. Ощущаю в воздухе. Многое из того, что было, ушло. И не осталось тех, кто помнит.

Всё началось с generic аргументов…

Один был добавлен для состояния, бессмертного и самого мудрого

Используя некоторые базовые типы из redux, мы можем типизировать большую часть аргументов:

function createYam<State>(handlers: any, context: any) {
  return (store: MiddlewareAPI<Dispatch, State>) => {
    let injectedHandlers = [];

    return (next: Dispatch) => (action: AnyAction) => {
      // the code didn't change
    };
  };
}

Правда, сейчас вся польза, которую мы получили — это тип значения, возвращаемого из store.getState(). Хочу обратить внимание, что тут необходимо использовать именно MiddlewareAPI, но не Store, так как в мидлвари передаётся не весь интерфейс: replaceReducer и subscribe опускаются.

Сразу же мы можем прокинуть этот тип в createStateChangedBy:

function createStateChangedBy<State>(prevState: State, nextState: State) {
  const cache = new Map<(state: State) => unknown, boolean>();
  return (selector: (state: State) => unknown) => {
    if (!cache.has(selector)) {
      cache.set(selector, selector(prevState) !== selector(nextState));
    }
    return !!cache.get(selector);
  };
}

При этом нам категорически без разницы, что именно возвращает селектор. Главное, чтобы он принимал конкретное состояние, что мы и обозначаем в типах.

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

Другой — отдан контексту, великому доставщику утилит без создания циклических зависимостей

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

function createYam<
  State,
  Context extends Record<string, unknown> | undefined
>(
  handlers: any,
  context: Context = undefined as Context
) {
  return (store: MiddlewareAPI<Dispatch, State>) => {
    let injectedHandlers = [];

    return (next: Dispatch) => (action: AnyAction) => {
      // the code didn't change
    };
  };
}

И вот тут начинается первая магия (я имею в виду undefined as Context). Дело в том, что какой-нибудь абстрактный юзер может явно передать в Context какой-нибудь тип, но при этом опустить второй аргумент функции. И, если переданный тип будет несовместим с undefined, мы окажемся у разбитого корыта с некорректной типизацией. Однако TS прекрасно выводит типы самостоятельно, поэтому если передать хотя бы один явно типизированный обработчик, то типы для состояния и контекста выведутся корректно.

Я говорю всё это к тому, что при вызове createYam не нужно явно указывать значения generic-параметров. Да и вообще при работе с TS мы скорее хотим этого не делать, нежели наоборот. На моём опыте, автоматическое выведение всегда работает более качественно (и более гибко).

А ещё, ещё один тип был сделан для обработчиков, которые больше всего жаждали власти (над побочными эффектами)

Основная часть, которая подвержена типизации — это аргумент. Помимо состояния и контекста, которые мы ввели ранее, он также будет зависеть от действия. Ведь это так прекрасно, когда в createYamHandler мы сопоставляем друг другу действие и обработчик и точно знаем тип действия, который в этом обработчике нужно ожидать. Поэтому тип будет выглядеть так:

type HandlerArg<
  State,
  Context extends Record<string, unknown> | undefined = undefined,
  Action extends AnyAction = AnyAction
> = {
  state: State;
  action: Action;
  stateChangedBy: (selector: (state: State) => unknown) => boolean;
  context: Context;
};

Таким образом типизировать обработчик не составляет труда:

function getFilters(state: AppState): Filter[];

async function handleSearch({ state, stateChangedBy }: HandlerArg<AppState>) {
  if (stateChangedBy(getFilters)) {
    const result = await fetchSearchResult(getFilters(state));
    return searchResultReceived(result);
  }
  return searchCancelled();
}

Для внутренней типизации нам также пригодится тип самого обработчика:

type Handler<
  State,
  Context extends Record<string, unknown> | undefined = undefined,
  Action extends AnyAction = AnyAction
> = (
  arg: HandlerArg<State, Context, Action>
) => void | Promise<void | AnyAction>;

Из всех аргументов обязательным является только тип состояния. Context может не использоваться, а действие, передаваемое обработчику, строго говоря, может быть любым. Поэтому такой набор аргументов кажется мне оптимальным.

И прежде чем добавить типы в createYam, нам осталось типизировать ещё одно место, связанное с обработчиками: инъекции. Чтобы грамотно разграничить типы действий, используемых внутри createYam, пришлось добавить ещё немного магии:

const handlerRequiredType = Symbol("handlerRequired");
const handlerRejectedType = Symbol("handlerRejected");

// we should use `any` because strictly typed payload
// does not bind us to initial middleware types,
// so you should validate the types for injected handlers yourself
function handlerRequired(handler: Handler<any, any>) {
  return {
    type: handlerRequiredType,
    payload: handler,
  };
}
function handlerRejected(handler: Handler<any, any>) {
  return {
    type: handlerRejectedType,
    payload: handler,
  };
}

// a couple of type guards
function isHandlerRequiredAction(
  action: AnyAction
): action is ReturnType<typeof handlerRequired> {
  return action.type === handlerRequiredType;
}
function isHandlerRejectedAction(
  action: AnyAction
): action is ReturnType<typeof handlerRejected> {
  return action.type === handlerRejectedType;
}

Использование type guards будет необходимо, потому что проверка action.type === handlerRequiredType на переменной типа AnyAction | ReturnType<typeof handlerRequired> никоим образом не может сузить тип до ReturnType<typeof handlerRequired>. Это связано с тем, что AnyAction также может иметь в качестве своего типа созданный нами символ, а мы не делаем никаких дополнительных проверок, чтобы это предотвратить.

Иными словами, совпадение action.type ещё не означает, что это обязательно нечто, что мы вернули из handlerRequired. Однако не будет же пользователь доставать из глубин библиотеки этот символ для создания действия, которое всё сломает. Не будет ведь?

Ну да ладно. В итоге получаем красиво типизированную мидлварь:

function createYam<
  State,
  Context extends Record<string, unknown> | undefined
>(
  // it accespts handlers for any actions
  handlers: Handler<State, Context>[],
  context: Context = undefined as Context
) {
  return (store: MiddlewareAPI<Dispatch, State>) => {
    // it can inject handlers for any actions, too
    // but we expect that they'll work with the same State and Context
    let injectedHandlers = [] as Handler<State, Context>[];

    // we expect any action here
    return (next: Dispatch) => (action: AnyAction) => {
      // type guard #1
      if (isHandlerRequiredAction(action)) {
        if (!injectedHandlers.includes(action.payload)) {
          injectedHandlers = [...injectedHandlers, action.payload];
        }
        return;
      }

      // type guard #2
      if (isHandlerRejectedAction(action)) {
        injectedHandlers = injectedHandlers.filter((h) => h !== action.payload);
        return;
      }
      // the code didn't change
    };
  };
}

Единственный момент, который мне не удалось решить — это, так сказать, истинная опциональность контекста. Если вы передаёте контекст в мидлварь, все обработчики должны быть типизированы так, будто его ожидают (даже если не используют). Не получится опустить второй generic аргумент в HandlerArg, иначе createYam не примет такой обработчик, неправильно выведя тип. В целом, это не такая большая трагедия, ведь можно завести локальный тип: type AppHandlerArg = HandlerArg<AppState, AppYamContext> и использовать в обработчиках его, с уже подставленными состоянием и контекстом.

Но был сделан ещё один тип, подчинявший себе все другие

Ну вот мы и подобрались к самой сочной части нашего проекта. Сама мидлварь уже полностью типизирована и готова к работе, без типов у нас осталась лишь одна единственная вспомогательная функция: createYamHandler, наша прелесссть.

И начну я с одной большой проблемы, которая начала вызвать у меня боль ещё при попытке организовать типизацию для function createReducer(Record<ActionType, Reducer>): Reducer: невозможно в общем типизировать значения объекта в зависимости от ключа. Ну нельзя, и всё тут. Если бы я заранее написал конкретный объект: да, пожалуйста, TS их видит. Но при попытке обобщить всё просто перестаёт работать. Возможно, в будущем TS предоставит нам для этого какой-то интерфейс, но сейчас его просто нет.

Но ведь это смотрится странно. Если я говорю, что обработчик будет реагировать на конкретное действие, почему же в самом указанном обработчике действие всё ещё может быть любым? Я об этом:

function testAction() {
  return { type: "test" };
}

createYamHandler({
  test: ({ action }) => {
    // action has `AnyAction` type,
    // not the `ReturnType<typeof testAction>` one
  },
});

Однако если посмотреть на этот код, становится понятно, что нет возможности сопоставить testAction и какую-то рандомную строку 'test', которая, оказывается, соответствует значению свойства у объекта, возвращаемого из функции, которая объявлена на игле, которая в яйце, которое в утке, которая… думаю, вы поняли.

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

К счастью, я не первый, кто добрался до этой проблемы, поэтому у меня было, на что опереться: те же ребята, написавшие Redux ToolKit, прекрасно справились с задачей. Если вкратце, то суть такова: мы не можем типизировать пару ключ-значение в объекте, но мы можем типизировать функцию, принимающую два аргумента! Дальше всё ограничивается только вашей фантазией. RTK выбрал путь строителя:

createReducer(initialState, (builder) =>
  builder
    .addCase(firstActionCreator, (state, firstAction) => nextState)
    .addCase(secondActionCreator, (state, secondAction) => nextState)
);

Тут происходит две важные вещи:

  1. В соответствие кейсу ставится уже не тип в виде строки, но целый `actionCreator`.
  2. Он содержит в себе не только строку action.type, по которой можно делать условный switch в редьюсере, но и тип для остальных полей в этом действии, который можно явно использовать в функции, переданной вторым аргументом.

Если очень грубо, тип получается примерно таким:

type Builder<TState> = {
  addCase: <TAction>(
    actionCreator: (...args: any[]) => TAction,
    reducer: (state: TState, action: TAction) => TState
  ) => Builder<TState>;
};

Как правило, в подобных решениях при создании actionCreator к функции также добавляется поле type, возвращающее строку, с которой это действие было создано, поэтому по сути мы имеем фабрику и константу для строки типа в одном лице:

const action = createAction("action_type", (arg: string) => ({ payload: arg }));
action("payload for the action"); // { type: 'action_type', payload: 'payload for the action' }
action.type; // 'action_type'

Лично мне больше понравился формат, использованный в другой библиотеке, под названием deox. Она делает ровно то же самое, но имеет, на мой взгляд, более приятный интерфейс для редьюсеров:

createReducer(initialState, (handle) => [
  handle(firstActionCreator, (state, firstAction) => nextState),
  handle(secondActionCreator, (state, secondAction) => nextState),
]);

Так что для реализации подобной типизации в yam я решил взять вариант с handle за основу.

Давайте начнём работу над createYamHandler. Для начала нам придётся разобраться с аргументом. Раньше он принимал только словарь, где типу сопоставлялся обработчик, теперь же он может принимать ещё и функцию. Высокоуровнево это выглядит так:

function createYamHandler<
  State,
  Context extends Record<string, unknown> | undefined = undefined
>(
  handlerMap:
    | Record<string, Handler<State, Context>>
    // new magic line
    | HandlerMapBuilder<State, Context>
) {
  const map =
    // two new magic lines
    typeof handlerMap === "function"
      ? createHandlerMap(handlerMap)
      : handlerMap;
  return (arg: HandlerArg<State, Context>) => map[arg.action.type]?.(arg);
}

Здесь появляется некий map builder, из которого мы можем создать нужный нам словарь, да так типизированный (builder, то есть), чтобы для пользователя, создающего пары из actionCreatorов и обработчиков, правильно подставлялись типы действий.

Сам строитель имеет такой тип:

type HandlerMapBuilder<
  State,
  Context extends Record<string, unknown> | undefined
> = (
  handle: HandlerMapBuilderCallback<State, Context>
) => [string, Handler<State, Context>][];

Как можно видеть в примере из deox, это некая функция, принимающая волшебный handle и возвращающая нечто, что поможет нам построить наш словарь. Тип возвращаемого значения — деталь чисто внутренняя, поэтому я выбрал [string, Handler] пары как самый простой и прямолинейный вариант (на мой взгляд). Сам же callback содержит в себе основную магическую магию, добавляя индивидуальный generic аргумент для каждого вызова:

type HandlerMapBuilderCallback<
  State,
  Context extends Record<string, unknown> | undefined
> = <ActionCreator extends (...args: any[]) => AnyAction>(
  actionCreator: ActionCreator & { type: ReturnType<ActionCreator>["type"] },
  handler: Handler<State, Context, ReturnType<ActionCreator>>
) => [ReturnType<ActionCreator>["type"], Handler<State, Context>];

На этом этапе уже может начать становиться страшно, поэтому давайте разберёмся по порядку:

  1. State и Context — уже привычные нам generic аргументы, которые мы добавили в самом начале; здесь они передаются извне.
  2. ActionCreator — generic аргумент, который можно явно передать функции при вызове или дать TS самому его вывести; главное, что на каждом вызове он может быть разным, в отличие от State и Context.
  3. ActionCreator, вообще говоря, может быть любой функцией, возвращающей AnyAction aka { type: extends string; [key: string]: any }, но в аргументе мы также говорим, что нам важно, чтобы эта функция имела на себе свойство type, соответствующее типу возвращаемого действия.
  4. Второй аргумент — это обработчик, в тип которого мы в первый (и единственный) раз передали третий generic аргумент: тип обрабатываемого действия.
  5. Возвращаем мы пары тип/обработчик, из которых мы потом будем собирать словарь.

Это крайне интересный пример типизации, до которого я, наверное, сам никогда и не догадался бы, если не столкнулся бы с такой проблемой, но работает он на ура. Единственное, что в нашем примере осталось неопределённым, это createHandlerMap. Он вроде бы и простой концептуально, но вот там над TS придётся немного поглумиться, потому что он либо слишком умный, либо недостаточно — я так и не понял.

function createHandlerMap<
  State,
  Context extends Record<string, unknown> | undefined
>(handlerBuilder: HandlerMapBuilder<State, Context>) {
  const handle: HandlerMapBuilderCallback<State, Context> = (
    actionCreator,
    handler
  ) => [actionCreator.type, handler as Handler<State, Context>];
  return Object.fromEntries(handlerBuilder(handle)) as Record<
    string,
    Handler<State, Context>
  >;
}

Самое страшное место здесь, которого, как я понимаю, невозможно избежать — это handler as Handler<State, Context>. Сперва мы доказываем TS, что наш обработчик работает над одним конкретным действием, а потом такие «нет, знаешь, вообще-то над любым».

Возможно, виной тому необходимость вернуться к Record<string, Handler>, который оставляет эту связь между типами только в run-time. Но без такого преобразования пользователь будет получать ошибку типа «ваш обработчик, конечно, подходит к выставленному ограничению, но может быть инициализирован другим подтипом, который не совместим с вашим» (собственно, с той же ошибкой мы сталкивались при типизации контекста и установке для него значения по умолчанию).

Так что тут нам придётся сказать TS: «Я знаю, что делаю, не ругай меня, пожалуйста, мне уже есть 18». Ну а fromEntries просто не имеет нормальной типизации, так что там мы его даже не обманываем.

Собрав всё воедино, получаем такую простыню:

type HandlerMapBuilderCallback<
  State,
  Context extends Record<string, unknown> | undefined
> = <ActionCreator extends (...args: any[]) => AnyAction>(
  actionCreator: ActionCreator & { type: ReturnType<ActionCreator>["type"] },
  handler: Handler<State, Context, ReturnType<ActionCreator>>
) => [ReturnType<ActionCreator>["type"], Handler<State, Context>];

type HandlerMapBuilder<
  State,
  Context extends Record<string, unknown> | undefined
> = (
  handle: HandlerMapBuilderCallback<State, Context>
) => [string, Handler<State, Context>][];

function createYamHandler<
  State,
  Context extends Record<string, unknown> | undefined = undefined
>(
  handlerMap:
    | Record<string, Handler<State, Context>>
    | HandlerMapBuilder<State, Context>
) {
  const map =
    typeof handlerMap === "function"
      ? createHandlerMap(handlerMap)
      : handlerMap;
  return (arg: HandlerArg<State, Context>) => map[arg.action.type]?.(arg);
}

function createHandlerMap<
  State,
  Context extends Record<string, unknown> | undefined
>(handlerBuilder: HandlerMapBuilder<State, Context>) {
  const handle: HandlerMapBuilderCallback<State, Context> = (
    actionCreator,
    handler
  ) => [actionCreator.type, handler as Handler<State, Context>];
  return Object.fromEntries(handlerBuilder(handle)) as Record<
    string,
    Handler<State, Context>
  >;
}

И эта простыня позволяет нам написать прекрасное

createYamHandler((handle) => [
  handle(firstAction, (argWithFirstAction) => {
    /* ... */
  }),
  handle(secondAction, (argWithSecondAction) => {
    /* ... */
  }),
]);

И каждый обработчик наверняка знает, с каким типом он будет работать.

Заключение

Вот мы и завершили типизацию очередной мидлвари для redux. Найти репозиторий с полным кодом можно здесь.

В ближайшее время я постараюсь подготовить ещё одну статью, в которой соберу различные лайфхаки по использованию библиотеки. Потому что инструмент достаточно свободный, и сделать можно практически всё что угодно (и мы с вами прекрасно знаем, что на инфраструктуре redux это отразилось не лучшим образом), поэтому как-то направить рвения народа лишним не будет.

И, конечно, спасибо большое, что участвовали вместе со мной в этом замечательном приключении (хоть и как молчаливые зрители). Это были первые мои статьи на техническую тему, но кто знает, может, я войду во вкус и продолжу радовать вас контентом. В любом случае мир тесен, так что где-нибудь и когда-нибудь мы точно с вами встретимся.

Что думаете?