Расширение типов middleware

Система middleware в библиотеке Floating UI построена на цепочке последовательных преобразований координат. Каждый middleware — это функция, которая получает текущее состояние позиционирования (x, y, placement, rects, elements и др.) и возвращает модифицированное состояние.

Базовый интерфейс middleware:

type Middleware = {
  name: string;
  options?: any;
  fn: (state: MiddlewareState) => MiddlewareReturn;
};

Расширение типов middleware позволяет:

  • усиливать типизацию пользовательских решений
  • обеспечивать строгую проверку опций
  • создавать переиспользуемые композиции
  • интегрировать middleware в крупные TypeScript-проекты без потери безопасности типов

Типизация состояния middleware

Floating UI предоставляет базовый тип MiddlewareState, который можно расширять:

interface MiddlewareState {
  x: number;
  y: number;
  placement: Placement;
  strategy: Strategy;
  middlewareData: Record<string, any>;
  elements: {
    reference: Element;
    floating: HTMLElement;
  };
  rects: {
    reference: DOMRect;
    floating: DOMRect;
  };
}

Для расширения:

interface ExtendedMiddlewareState extends MiddlewareState {
  customData?: {
    offsetCache: number;
  };
}

Это позволяет добавлять внутренние данные, используемые несколькими middleware.


Типизация опций middleware

Каждый middleware может принимать объект options. Без строгой типизации это приводит к ошибкам.

Пример базового middleware:

const customOffset = (options?: { offset: number }) => ({
  name: 'customOffset',
  options,
  fn(state) {
    return {
      ...state,
      y: state.y + (options?.offset ?? 0),
    };
  },
});

Улучшенный вариант с типами:

type CustomOffsetOptions = {
  offset: number;
};

const customOffset = (options: CustomOffsetOptions): Middleware => ({
  name: 'customOffset',
  options,
  fn(state: MiddlewareState) {
    return {
      ...state,
      y: state.y + options.offset,
    };
  },
});

Дженерики для middleware

Для повышения гибкости используются дженерики:

type MiddlewareWithOptions<T> = {
  name: string;
  options: T;
  fn: (state: MiddlewareState) => MiddlewareReturn;
};

Пример:

function createMiddleware<T>(name: string, fn: (state: MiddlewareState, options: T) => MiddlewareReturn) {
  return (options: T): MiddlewareWithOptions<T> => ({
    name,
    options,
    fn: (state) => fn(state, options),
  });
}

Использование:

const shiftBy = createMiddleware<{ dx: number; dy: number }>(
  'shiftBy',
  (state, options) => ({
    ...state,
    x: state.x + options.dx,
    y: state.y + options.dy,
  })
);

Расширение middlewareData

middlewareData используется для передачи данных между middleware:

middlewareData: {
  [name: string]: any;
}

Расширение типов:

interface CustomMiddlewareData {
  customOffset?: {
    applied: boolean;
    value: number;
  };
}

Объединение:

type ExtendedState = MiddlewareState & {
  middlewareData: MiddlewareState['middlewareData'] & CustomMiddlewareData;
};

Пример middleware с данными:

const customOffset = (offset: number): Middleware => ({
  name: 'customOffset',
  fn(state: ExtendedState) {
    return {
      ...state,
      y: state.y + offset,
      middlewareData: {
        ...state.middlewareData,
        customOffset: {
          applied: true,
          value: offset,
        },
      },
    };
  },
});

Типизация цепочек middleware

При использовании массива middleware возникает проблема потери типов:

computePosition(reference, floating, {
  middleware: [offset(10), flip(), shift()],
});

Для строгой типизации:

type TypedMiddlewareArray = Array<MiddlewareWithOptions<any>>;

Более строгий вариант с объединением:

type MiddlewareUnion =
  | MiddlewareWithOptions<{ offset: number }>
  | MiddlewareWithOptions<{ padding: number }>;

Композиция middleware

Создание комбинированных middleware:

function composeMiddleware(...middlewares: Middleware[]): Middleware {
  return {
    name: 'composed',
    fn(state) {
      return middlewares.reduce(
        (acc, middleware) => middleware.fn(acc),
        state
      );
    },
  };
}

Типизированный вариант:

function composeMiddlewareTyped<T extends Middleware[]>(...middlewares: T): Middleware {
  return {
    name: 'composed',
    fn(state) {
      return middlewares.reduce((acc, m) => m.fn(acc), state);
    },
  };
}

Асинхронные middleware

Floating UI поддерживает асинхронные вычисления. Типы необходимо расширить:

type AsyncMiddleware = {
  name: string;
  fn: (state: MiddlewareState) => Promise<MiddlewareReturn>;
};

Пример:

const asyncMiddleware: AsyncMiddleware = {
  name: 'asyncExample',
  async fn(state) {
    const data = await fetch('/api/position').then(r => r.json());

    return {
      ...state,
      x: state.x + data.offsetX,
    };
  },
};

Условные типы для middleware

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

type OffsetOptions =
  | { type: 'static'; value: number }
  | { type: 'dynamic'; compute: (state: MiddlewareState) => number };

Реализация:

const offsetMiddleware = (options: OffsetOptions): Middleware => ({
  name: 'offset',
  fn(state) {
    const offset =
      options.type === 'static'
        ? options.value
        : options.compute(state);

    return {
      ...state,
      y: state.y + offset,
    };
  },
});

Расширение Placement и Strategy

Floating UI использует типы:

type Placement = 'top' | 'bottom' | 'left' | 'right';
type Strategy = 'absolute' | 'fixed';

Расширение:

type ExtendedPlacement = Placement | 'center';

interface ExtendedState extends MiddlewareState {
  placement: ExtendedPlacement;
}

Пример middleware:

const centerPlacement: Middleware = {
  name: 'centerPlacement',
  fn(state: ExtendedState) {
    if (state.placement === 'center') {
      return {
        ...state,
        x: state.rects.reference.width / 2,
        y: state.rects.reference.height / 2,
      };
    }
    return state;
  },
};

Интеграция с внешними типами

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

interface ThemeConfig {
  spacing: number;
}

const themedOffset = (theme: ThemeConfig): Middleware => ({
  name: 'themedOffset',
  fn(state) {
    return {
      ...state,
      y: state.y + theme.spacing,
    };
  },
});

Проверка типов middleware

Для предотвращения ошибок:

function isMiddleware(obj: any): obj is Middleware {
  return typeof obj?.name === 'string' && typeof obj?.fn === 'function';
}

Расширение через декларации (Declaration Merging)

В TypeScript можно расширить глобальные типы:

declare module '@floating-ui/core' {
  interface MiddlewareData {
    customOffset?: {
      value: number;
    };
  }
}

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

state.middlewareData.customOffset?.value;

Типобезопасные фабрики middleware

Создание фабрик повышает переиспользуемость:

function createOffsetMiddleware<T extends number>(offset: T) {
  return {
    name: 'offset',
    options: offset,
    fn(state: MiddlewareState) {
      return {
        ...state,
        y: state.y + offset,
      };
    },
  };
}

Ограничения и типовые проблемы

Потеря типов в массиве

const middleware = [customOffset({ offset: 10 }), shift()];

TypeScript приводит к Middleware[], теряя конкретные типы.

Решение:

  • использовать as const
  • явно задавать тип массива

Практика проектирования расширяемых middleware

Ключевые принципы:

  • Изоляция логики — каждый middleware выполняет одну задачу
  • Явная типизация — избегание any
  • Минимизация побочных эффектов
  • Переиспользуемость через фабрики
  • Стандартизация структуры middlewareData

Пример комплексного расширения

type TooltipOptions = {
  offset: number;
  arrowSize: number;
};

const tooltipMiddleware = (options: TooltipOptions): Middleware => ({
  name: 'tooltip',
  options,
  fn(state) {
    const { offset, arrowSize } = options;

    return {
      ...state,
      y: state.y + offset,
      middlewareData: {
        ...state.middlewareData,
        tooltip: {
          arrowOffset: arrowSize / 2,
        },
      },
    };
  },
});

Такой подход объединяет:

  • строгую типизацию опций
  • расширение состояния
  • передачу данных между middleware
  • подготовку к сложным UI-компонентам