Инференс типов из конфигурации

Механизм инференса типов в контексте конфигурации Popper.js становится особенно актуальным при использовании TypeScript. Он позволяет автоматически выводить типы на основе переданных параметров, минимизируя количество явных аннотаций и снижая вероятность ошибок.


Структура конфигурации Popper

Конфигурация Popper состоит из нескольких ключевых полей:

  • placement
  • modifiers
  • strategy
  • onFirstUpdate

Типизация этих полей задаётся через обобщённые типы (generics) и условные конструкции TypeScript.

Пример базовой конфигурации:

import { createPopper } from '@popperjs/core';

const popper = createPopper(referenceElement, popperElement, {
  placement: 'top',
  modifiers: [
    {
      name: 'offset',
      options: {
        offset: [0, 8],
      },
    },
  ],
});

Вывод типов для placement

Поле placement принимает строго ограниченный набор строковых значений:

type Placement =
  | 'auto'
  | 'top'
  | 'bottom'
  | 'right'
  | 'left'
  | 'top-start'
  | 'top-end'
  | 'bottom-start'
  | 'bottom-end';

При передаче значения напрямую TypeScript автоматически сужает тип:

const config = {
  placement: 'top',
};

Тип config.placement будет 'top', а не string, благодаря литеральному типу.

Однако при вынесении значения в переменную без as const происходит расширение:

const placement = 'top';
const config = {
  placement,
};

Теперь placement имеет тип string, и теряется точность. Для сохранения инференса используется:

const placement = 'top' as const;

или:

const config = {
  placement: 'top',
} as const;

Инференс типов модификаторов

Массив modifiers — наиболее сложная часть конфигурации с точки зрения типизации. Каждый модификатор имеет структуру:

type Modifier<Name, Options> = {
  name: Name;
  enabled?: boolean;
  phase?: ModifierPhases;
  fn?: (state: State) => void;
  options?: Options;
};

TypeScript способен выводить тип options на основе имени модификатора, если используется заранее описанный набор:

import { Modifier } from '@popperjs/core';

const offsetModifier: Modifier<'offset', { offset: [number, number] }> = {
  name: 'offset',
  options: {
    offset: [0, 10],
  },
};

Однако при использовании встроенных модификаторов инференс происходит автоматически:

const config = {
  modifiers: [
    {
      name: 'offset',
      options: {
        offset: [0, 8],
      },
    },
  ],
};

TypeScript понимает, что offset — это массив из двух чисел, благодаря встроенным декларациям типов.


Расширение конфигурации и пользовательские модификаторы

При создании пользовательских модификаторов важно сохранить корректный инференс:

type CustomOptions = {
  customValue: number;
};

const customModifier: Modifier<'custom', CustomOptions> = {
  name: 'custom',
  enabled: true,
  phase: 'main',
  fn({ state, options }) {
    console.log(options.customValue);
  },
};

Если не указать тип CustomOptions, поле options будет иметь тип any, что разрушает преимущества строгой типизации.


Обобщённые типы в createPopper

Функция createPopper использует обобщения для связывания типов элементов и конфигурации:

function createPopper<
  TReferenceElement extends Element | VirtualElement,
  TPopperElement extends HTMLElement
>(
  reference: TReferenceElement,
  popper: TPopperElement,
  options?: Partial<Options>
): Instance;

Инференс происходит автоматически:

const instance = createPopper(buttonElement, tooltipElement);

TypeScript выводит:

  • TReferenceElement из buttonElement
  • TPopperElement из tooltipElement

Частичный конфиг (Partial<Options>)

Конфигурация передаётся как Partial<Options>, что позволяет указывать только нужные поля:

const config = {
  placement: 'bottom',
};

TypeScript не требует полного описания всех свойств, но при этом сохраняет строгую проверку существующих.


Инференс через as const

Ключевой инструмент для точного вывода типов — as const.

Без него:

const modifiers = [
  {
    name: 'offset',
    options: { offset: [0, 8] },
  },
];

Тип name будет string, а не 'offset'.

С as const:

const modifiers = [
  {
    name: 'offset',
    options: { offset: [0, 8] },
  },
] as const;

Теперь:

  • name имеет тип 'offset'
  • offset[0, 8], а не number[]

Это критично для корректной работы типовой системы Popper.


Условные типы и зависимости

Некоторые типы в Popper зависят друг от друга. Например, тип options зависит от name модификатора.

Внутри библиотеки используются условные типы:

type ModifierOptionsByName<T> =
  T extends 'offset' ? OffsetOptions :
  T extends 'flip' ? FlipOptions :
  unknown;

Это позволяет TypeScript автоматически подставлять нужную структуру options.


Ограничения инференса

Несмотря на мощные возможности, инференс имеет ограничения:

  1. Потеря литеральных типов при мутациях

    let config = { placement: 'top' };
    config.placement = 'bottom';

    Тип становится string.

  2. Сложные вычисляемые значения

    const placement = getPlacement();

    Тип не может быть выведен точно.

  3. Массивы без as const

    const arr = ['top', 'bottom'];

    Тип: string[], а не ('top' | 'bottom')[].


Практика строгой типизации конфигурации

Рекомендуемый подход:

import type { Options } from '@popperjs/core';

const config: Partial<Options> = {
  placement: 'right',
  modifiers: [
    {
      name: 'offset',
      options: {
        offset: [0, 12],
      },
    },
  ],
};

Либо более строгий вариант:

const config = {
  placement: 'right',
  modifiers: [
    {
      name: 'offset',
      options: {
        offset: [0, 12],
      },
    },
  ],
} as const;

Выгоды инференса типов

  • Снижение количества ручных аннотаций
  • Автоматическая проверка корректности конфигурации
  • Улучшенная автодополняемость в IDE
  • Предотвращение ошибок на этапе компиляции

Связь с архитектурой Popper

Инференс типов напрямую связан с архитектурой модификаторов:

  • каждый модификатор описывает свои options
  • конфигурация агрегирует их
  • TypeScript связывает всё через обобщения и условные типы

Это делает Popper.js примером библиотеки с глубокой и продуманной типовой системой, где конфигурация становится не просто объектом, а строго проверяемой структурой.