TypeScript в effector

Effector предоставляет первоклассную поддержку TypeScript из коробки, что дает вам надежную типизацию и отличный опыт разработки при работе с библиотекой. В этом разделе мы рассмотрим как базовые концепции типизации, так и продвинутые техники работы с типами в effector.

Типизация событий

События в effector могут быть типизированы при помощи передачи типа в дженерик функции, однако если не передавать ничего, то в таком случае событие будет с типом EventCallable<void>:

import { createEvent } from "effector";

// Событие без параметров
const clicked = createEvent();
// EventCallable<void>

// Событие с параметром
const userNameChanged = createEvent<string>();
// EventCallable<string>

// Событие со сложным параметром
const formSubmitted = createEvent<{
  username: string;
  password: string;
}>();
// EventCallable<{ username: string;password: string; }>

Типы событий

В effector для событий может быть несколько типов, где T - тип хранимого значения:

  1. EventCallable<T> - событие, которое может вызвать.
  2. Event<T> - производное событие, которое нельзя вызвать в ручную.

Типизация методов событий

event.prepend

Чтобы добавить типы к событиям, созданным с помощью event.prepend, необходимо добавить тип либо в аргумент функции prepend, либо как дженерик

const message = createEvent<string>();

const userMessage = message.prepend((text: string) => text);
// userMessage имеет тип EventCallable<string>

const warningMessage = message.prepend<string>((warnMessage) => warnMessage);
// warningMessage имеет тип EventCallable<string>

Типизация сторов

Сторы также можно типизировать при помощи передачи типа в дженерик функции, либо указав дефолтное значение при инициализации, тогда ts будет выводить тип из этого значения:

import { createStore } from "effector";

// Базовый стор с примитивным значением
// StoreWritable<number>
const $counter = createStore(0);

// Стор со сложным объектным типом
interface User {
  id: number;
  name: string;
  role: "admin" | "user";
}

// StoreWritable<User>
const $user = createStore<User>({
  id: 1,
  name: "Bob",
  role: "user",
});

// Store<string>
const $userNameAndRole = $user.map((user) => `User name and role: ${user.name} and ${user.role}`);

Типы сторов

В эффектор существуют два типа сторов, где T - тип хранимого значения:

  1. Store<T> - тип производного стора, в который нельзя записать новые данные.
  2. StoreWritable<T> - тип стора, в который можно записывать новые данные при помощи on или sample.

Типизация эффектов

При обычном использовании TypeScript будет выводить типы в зависимости от возвращаемого результата функции, а также ее аргументов.
Однако, createEffect поддерживает типизацию входных параметров, возвращаемого результата и ошибок через дженерик:

import { createEffect } from "effector";

// Базовый эффект
// Effect<string, User, Error>
const fetchUserFx = createEffect(async (userId: string) => {
  const response = await fetch(`/api/users/${userId}`);
  const result = await response.json();

  return result as User;
});

Типизация функции обработчика вне эффекта

В случае, если функция обработчик определен вне эффекта, то для типизации вам нужно будет передать тип этой функции:

const sendMessage = async (params: { text: string }) => {
  // ...
  return "ok";
};

const sendMessageFx = createEffect<typeof sendMessage, AxiosError>(sendMessage);
// => Effect<{text: string}, string, AxiosError>

Кастомные ошибки эффекта

Некоторый код может выдать исключения только некоторых типов. В эффектах для описания типов ошибок используется третий дженерик Fail.

// Определяем типы ошибок API
interface ApiError {
  code: number;
  message: string;
}

// Создаём типизированный эффект
const fetchUserFx = createEffect<string, User, ApiError>(async (userId) => {
  const response = await fetch(`/api/users/${userId}`);

  if (!response.ok) {
    throw {
      code: response.status,
      message: "Failed to fetch user",
    } as ApiError;
  }

  return response.json();
});

Типизация методов

sample

Типизация filter

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

type UserMessage = { kind: "user"; text: string };
type WarnMessage = { kind: "warn"; warn: string };

const message = createEvent<UserMessage | WarnMessage>();
const userMessage = createEvent<UserMessage>();

sample({
  clock: message,
  filter: (msg): msg is UserMessage => msg.kind === "user",
  target: userMessage,
});

Если вам нужно произвести проверку в filter на существование данных, то вы можете просто передать Boolean:

import { createEvent, createStore, sample } from "effector";

interface User {
  id: string;
  name: string;
  email: string;
}

// События
const formSubmitted = createEvent();
const userDataSaved = createEvent<User>();

// Состояния
const $currentUser = createStore<User | null>(null);

// При сабмите формы отправляем данные только если юзер существует
sample({
  clock: formSubmitted,
  source: $currentUser,
  filter: Boolean, // отфильтровываем null
  target: userDataSaved,
});

// Теперь userDataSaved получит только существующие данные пользователя

Типизация filter и fn

Как упоминалось выше, если использовать предикаты типов в filter, то все отработает корректно и в target попадет нужный тип.
Однако, такая механика не отработает как нужно при использовании filter и fn вместе. В таком случае вам потребуется в ручную указать тип данных параметров filter, а также добавить предикаты типов. Это происходит из-за того, что TypeScript не может корректно вывести тип в fn после filter, если тип не указан явно. Это ограничение системы типов TypeScript.

type UserMessage = { kind: "user"; text: string };
type WarnMessage = { kind: "warn"; warn: string };
type Message = UserMessage | WarnMessage;

const message = createEvent<Message>();
const userText = createEvent<string>();

sample({
  clock: message,
  filter: (msg: Message): msg is UserMessage => msg.kind === "user",
  fn: (msg) => msg.text,
  target: userText,
});

// userMessage has type Event<string>
Оно стало умнее!

Начиная с TypeScript версии >= 5.5 вы можете не писать предикаты типов, а просто указать тип аргумента, а TypeScript сам поймет, что нужно вывести: filter: (msg: Message) => msg.kind === "user",

attach

Чтобы позволить TypeScript выводить типы создаваемого эффекта, можно добавить тип к первому аргументу mapParams, который станет дженериком Params у результата:

const sendTextFx = createEffect<{ message: string }, "ok">(() => {
  // ...

  return "ok";
});

const sendWarningFx = attach({
  effect: sendTextFx,
  mapParams: (warningMessage: string) => ({ message: warningMessage }),
});
// sendWarningFx имеет тип Effect<{message: string}, 'ok'>

split

Вы можете использовать предикаты типов для разделения исходного типа события на несколько вариантов:

type UserMessage = { kind: "user"; text: string };
type WarnMessage = { kind: "warn"; warn: string };

const message = createEvent<UserMessage | WarnMessage>();

const { userMessage, warnMessage } = split(message, {
  userMessage: (msg): msg is UserMessage => msg.kind === "user",
  warnMessage: (msg): msg is WarnMessage => msg.kind === "warn",
});
// userMessage имеет тип Event<UserMessage>
// warnMessage имеет тип Event<WarnMessage>

createApi

Чтобы позволить TypeScript выводить типы создаваемых событий, можно добавить тип ко второму аргументу обработчиков

const $count = createStore(0);

const { add, sub } = createApi($count, {
  add: (x, add: number) => x + add,
  sub: (x, sub: number) => x - sub,
});

// add имеет тип Event<number>
// sub имеет тип Event<number>

is

Методы группы is могут помочь вывести тип юнита, то есть они действуют как TypeScript type guards. Это применяется в написании типизированных утилит:

export function getUnitType(unit: unknown) {
  if (is.event(unit)) {
    // здесь юнит имеет тип Event<any>
    return "event";
  }
  if (is.effect(unit)) {
    // здесь юнит имеет тип Effect<any, any>
    return "effect";
  }
  if (is.store(unit)) {
    // здесь юнит имеет тип Store<any>
    return "store";
  }
}

merge

При объединении событий можно получить союз их типов:

import { createEvent, merge } from "effector";

const firstEvent = createEvent<string>();
const secondEvent = createEvent<number>();

const merged = merge([firstEvent, secondEvent]);
// Event<string | number>

// Можно также объединять события с одинаковыми типами
const buttonClicked = createEvent<MouseEvent>();
const linkClicked = createEvent<MouseEvent>();

const anyClick = merge([buttonClicked, linkClicked]);
// Event<MouseEvent>

merge принимает дженерик параметр, где можно указать какого типа событий он ожидает:

import { createEvent, merge } from "effector";

const firstEvent = createEvent<string>();
const secondEvent = createEvent<number>();

const merged = merge<number>([firstEvent, secondEvent]);
//                                ^
// Type 'EventCallable<string>' is not assignable to type 'Unit<number>'.

Утилиты для типов

Effector предоставляет набор утилитных типов для работы с типами юнитов:

UnitValue

Тип UnitValue служит для извлечение типа данных из юнитов:

import { UnitValue, createEffect, createStore, createEvent } from "effector";

const event = createEvent<{ id: string; name?: string } | { id: string }>();
type UnitEventType = UnitValue<typeof event>;
// {id: string; name?: string | undefined} | {id: string}

const $store = createStore([false, true]);
type UnitStoreType = UnitValue<typeof $store>;
// boolean[]

const effect = createEffect<{ token: string }, any, string>(() => {});
type UnitEffectType = UnitValue<typeof effect>;
// {token: string}

const scope = fork();
type UnitScopeType = UnitValue<typeof scope>;
// any

StoreValue

StoreValue по своей сути похож на UnitValue, но работает только со стором:

import { createStore, StoreValue } from "effector";

const $store = createStore(true);

type StoreValueType = StoreValue<typeof $store>;
// boolean

EventPayload

Извлекает тип данных из событий. Похож на UnitValue, но только для событий

import { createEvent, EventPayload } from "effector";

const event = createEvent<{ id: string }>();

type EventPayloadType = EventPayload<typeof event>;
// {id: string}

EffectParams

Принимает тип эффекта в параметры дженерика, позволяет получить тип параметров эффекта.

import { createEffect, EffectParams } from "effector";

const fx = createEffect<
  { id: string },
  { name: string; isAdmin: boolean },
  { statusText: string; status: number }
>(() => {
  // ...
  return { name: "Alice", isAdmin: false };
});

type EffectParamsType = EffectParams<typeof fx>;
// {id: string}

EffectResult

Принимает тип эффекта в параметры дженерика, позволяет получить тип возвращаемого значения эффекта.

import { createEffect, EffectResult } from "effector";

const fx = createEffect<
  { id: string },
  { name: string; isAdmin: boolean },
  { statusText: string; status: number }
>(() => ({ name: "Alice", isAdmin: false }));

type EffectResultType = EffectResult<typeof fx>;
// {name: string; isAdmin: boolean}

EffectError

Принимает тип эффекта в параметры дженерика, позволяет получить тип ошибки эффекта.

import { createEffect, EffectError } from "effector";

const fx = createEffect<
  { id: string },
  { name: string; isAdmin: boolean },
  { statusText: string; status: number }
>(() => ({ name: "Alice", isAdmin: false }));

type EffectErrorType = EffectError<typeof fx>;
// {statusText: string; status: number}
Перевод поддерживается сообществом

Документация на английском языке - самая актуальная, поскольку её пишет и обновляет команда effector. Перевод документации на другие языки осуществляется сообществом по мере наличия сил и желания.

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

Соавторы