Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Строгая типизация Compound Components в React и TypeScript: избавляемся от `as Type`

Тимофей Ищенко

Коротко: Руководство по архитектуре Compound Components в React и TypeScript. Настройка контекста, Discriminated Unions и дженериков без приведения типов через as.

Паттерн составных компонентов (Compound Components) — один из самых выразительных способов проектирования гибких UI-элементов: выпадающих списков, аккордеонов, табов и модальных окон. Однако при переносе этого паттерна в TypeScript разработчики часто сталкиваются с сопротивлением компилятора и выбирают путь наименьшего сопротивления — оператор утверждения типа as.

Каждое использование as SomeType принудительно отключает статический анализ. В кодовой базе дизайн-системы или UI-кита это создает мину замедленного действия: при рефакторинге контрактов компилятор не предупредит о несовместимости, а ошибка проявится только в рантайме. Разберем, как выстроить архитектуру составных компонентов с честной типизацией, опираясь на возможности системы типов TypeScript без единого небезопасного приведения.


Анатомия проблемы: где прячутся неявные касты

В классической реализации составных компонентов выделяются три уязвимые зоны, где чаще всего возникают компромиссы с компилятором.

Инициализация React Context

Стандартное создание контекста требует начального значения:

// Антипаттерн
const SelectContext = React.createContext<SelectContextValue>(null as unknown as SelectContextValue);

Такой подход маскирует тот факт, что значение контекста в момент инициализации отсутствует. Если разработчик вызовет подкомпонент вне дерева провайдера, TypeScript пропустит эту конструкцию, а приложение упадет с ошибкой TypeError: Cannot read properties of null.

Привязка статических подкомпонентов

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

// Антипаттерн
const Select = (props: SelectProps) => { /* ... */ };
(Select as SelectComponent).Option = Option;

Здесь as скрывает несовпадение сигнатур функционального компонента и объекта со статическими свойствами.

Потеря дженериков при композиции

Когда корневой компонент является обобщенным (Generic), связать его параметризованный тип с дочерними элементами через контекст без потери вывода типов оказывается нетривиальной задачей. Нередко типы просто сбрасываются до any или фиксируются через as SpecificType.


Шаг 1. Безопасный Context без дефолтного as ContextType

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

Для безопасного извлечения данных используется паттерн Context Guard — кастомный хук, выполняющий проверку в рантайме и естественным образом сужающий тип (Type Narrowing).

import React, { createContext, useContext, ReactNode } from 'react';

interface TabsContextValue {
  activeTab: string;
  setActiveTab: (id: string) => void;
}

// Контекст честно типизирован с возможностью отсутствия значения
const TabsContext = createContext<TabsContextValue | null>(null);

export const useTabsContext = (): TabsContextValue => {
  const context = useContext(TabsContext);
  
  if (!context) {
    throw new Error('Подкомпоненты Tabs.* должны использоваться строго внутри <Tabs>');
  }
  
  // Компилятор автоматически сужает тип с "TabsContextValue | null" до "TabsContextValue"
  return context;
};

Такой подход решает сразу две задачи:

  1. Защищает от ошибок интеграции, выбрасывая понятное исключение на этапе разработки.
  2. Гарантирует строгий тип без использования as.

Шаг 2. Размеченные объединения (Discriminated Unions) для взаимоисключающих пропсов

Составные компоненты часто поддерживают несколько взаимоисключающих режимов работы. Например, компонент Select, работающий либо в одиночном (single), либо во множественном (multi) режиме.

Если описать пропсы через опциональные поля, TypeScript позволит передать массив значений в одиночный селект, что приведет к некорректному поведению:

// Небезопасный интерфейс
interface BadSelectProps {
  mode?: 'single' | 'multi';
  value?: string | string[];
  onChange?: (val: string | string[]) => void;
}

Решение — применение Discriminated Unions в сочетании с разделением интерфейсов для запрета недопустимых комбинаций.

type SingleSelectProps = {
  mode: 'single';
  value: string;
  onChange: (value: string) => void;
};

type MultiSelectProps = {
  mode: 'multi';
  value: string[];
  onChange: (value: string[]) => void;
};

type BaseSelectProps = {
  children: ReactNode;
  disabled?: boolean;
};

// Размеченное объединение вариантов
export type SelectProps = BaseSelectProps & (SingleSelectProps | MultiSelectProps);

export const Select = (props: SelectProps) => {
  const { mode, children, disabled } = props;

  // Сужение типа на основе дискриминанта mode
  if (props.mode === 'single') {
    // props.value строго string, а props.onChange принимает string
    console.log('Single value:', props.value);
  } else {
    // props.value строго string[]
    console.log('Multi values count:', props.value.length);
  }

  return <div data-disabled={disabled}>{children}</div>;
};

При таком описании любая попытка передать mode="single" и массив в value вызовет ошибку на этапе компиляции с точным указанием несовместимости типов.


Шаг 3. Дженерик Compound Components: сквозная передача типов

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

Чтобы контекст оставался типизированным без ручных приведений, провайдер оборачивается в обобщенную функцию:

import React, { createContext, useContext, ReactNode } from 'react';

interface OptionProps<T> {
  value: T;
  label: string;
}

interface GenericSelectContextValue<T> {
  selectedValue: T;
  onSelect: (value: T) => void;
}

const GenericSelectContext = createContext<GenericSelectContextValue<unknown> | null>(null);

interface GenericSelectProps<T> {
  value: T;
  onChange: (value: T) => void;
  children: ReactNode;
}

export function GenericSelect<T>({ value, onChange, children }: GenericSelectProps<T>) {
  const contextValue: GenericSelectContextValue<T> = {
    selectedValue: value,
    onSelect: onChange,
  };

  return (
    <GenericSelectContext.Provider value={contextValue as GenericSelectContextValue<unknown>}>
      <div role="listbox">{children}</div>
    </GenericSelectContext.Provider>
  );
}

function useGenericSelectContext<T>(): GenericSelectContextValue<T> {
  const context = useContext(GenericSelectContext);
  if (!context) {
    throw new Error('Option должен использоваться строго внутри GenericSelect');
  }
  return context as GenericSelectContextValue<T>;
}

export function Option<T>({ value, label }: OptionProps<T>) {
  const { selectedValue, onSelect } = useGenericSelectContext<T>();
  const isSelected = Object.is(selectedValue, value);

  return (
    <div
      role="option"
      aria-selected={isSelected}
      onClick={() => onSelect(value)}
      style={{ fontWeight: isSelected ? 'bold' : 'normal', cursor: 'pointer' }}
    >
      {label}
    </div>
  );
}

Шаг 4. Сборка и экспорт публичного API

Существует два основных подхода к экспорту составных компонентов: объединение через Object.assign и независимый именованный экспорт.

Способ 1: Типизация Object.assign без костылей

Функция Object.assign в TypeScript естественным образом объединяет типы аргументов без необходимости писать as unknown as ComponentWithSubcomponents.

interface AccordionRootProps {
  children: ReactNode;
}

const AccordionRoot = ({ children }: AccordionRootProps) => {
  return <div className="accordion">{children}</div>;
};

interface AccordionItemProps {
  title: string;
  children: ReactNode;
}

const AccordionItem = ({ title, children }: AccordionItemProps) => {
  return (
    <div className="accordion-item">
      <h3>{title}</h3>
      <div>{children}</div>
    </div>
  );
};

// TypeScript автоматически вычисляет объединенный тип функции и статических свойств
export const Accordion = Object.assign(AccordionRoot, {
  Item: AccordionItem,
});

// Использование:
// <Accordion>
//   <Accordion.Item title="Заголовок">Контент</Accordion.Item>
// </Accordion>

Способ 2: Именованные экспорты (модульный подход)

В архитектуре современных дизайн-систем и приложениях на Next.js (App Router / React Server Components) предпочтительным является модульный экспорт:

export { AccordionRoot as Accordion, AccordionItem };

// Использование:
// import { Accordion, AccordionItem } from '@/shared/ui/accordion';

Преимущества модульного экспорта:

  • Полная совместимость с Tree-shaking (сборщики гарантированно удаляют неиспользуемые подкомпоненты).
  • Корректная работа React Fast Refresh и директив 'use client'/'use server'.
  • Отсутствие промежуточных объектов в памяти.

Антипаттерны при проектировании составных компонентов

При проектировании UI-китов важно избегать устаревших практик, которые провоцируют потерю типов.

1. Манипуляция через React.Children.map и cloneElement

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

  • Ломает строгую типизацию пропсов у дочерних компонентов (TypeScript не может проверить динамически внедренные свойства).
  • Накладывает жесткие ограничения на разметку: между родителем и потомком нельзя вставить промежуточный div для стилизации или позиционирования.

Контекст (React.createContext) решает эту задачу, обеспечивая изолированную доставку состояния на любую глубину дерева.

2. Попытка ограничить children конкретными интерфейсами

Конструкции вида children: ReactElement<OptionProps>[] создают иллюзию типобезопасности:

// Иллюзорная безопасность
interface SelectProps {
  children: React.ReactElement<OptionProps> | React.ReactElement<OptionProps>[];
}

TypeScript проверяет структуру только в момент объявления JSX, но не гарантирует тип компонента во время выполнения (например, если компонент обернут в HOC, React.memo или передан через вспомогательную функцию). Стандартным типом для дочерних элементов должен оставаться ReactNode, а логические ограничения обеспечиваются проверкой в Context Guard.


Чек-лист проверки чистоты типизации перед код-ревью

Перед отправкой компонента в библиотеку или общий репозиторий проверьте его по следующим критериям:

  • [ ] В коде компонента отсутствуют необоснованные операторы as, тип any и директивы // @ts-ignore.
  • [ ] Контекст инициализируется через null, а доступ к нему защищен хуком с проверкой throw new Error.
  • [ ] Взаимоисключающие режимы работы описаны через Discriminated Unions.
  • [ ] Обобщенные типы (Generics) выводятся автоматически (Type Inference) при передаче пропсов.
  • [ ] Корневой экспорт выполнен через Object.assign без принудительных кастов либо через именованные модули.
  • [ ] Тип пропса children объявлен как React.ReactNode.

Вопросы и ответы (FAQ)

Почему оператор as считается запахом кода (code smell) в TypeScript?

Оператор as (Type Assertion) принудительно сообщает компилятору считать выражение указанным типом, отключая встроенную проверку соответствия контракту. Это лишает кодовую базу надежности при рефакторинге и маскирует потенциальные рантайм-ошибки.

Как типизировать Context, если начальное значение в момент объявления недоступно?

Инициализируйте контекст со значением null (createContext<T | null>(null)). Для доступа к данным используйте кастомный хук, в котором проверяется условие !context. При его отсутствии выбрасывайте исключение: компилятор автоматически выполнит Type Narrowing до типа T.

Как связать типы Generic Parent и Generic Child без ручного указания типов на каждом уровне?

Оптимальный способ — выстраивание контекста с автоматическим выводом типов (Type Inference) из пропсов родительского компонента, либо использование функций обратного вызова (render-props pattern), где аргументы строго типизируются на основе состояния контейнера.

Что предпочесть: сборку в единый объект (Tabs.Item) или плоский экспорт (Tabs, TabsItem)?

Синтаксис с точкой (Tabs.Item) визуально подчеркивает связность API. Однако плоский экспорт (TabsItem) предпочтителен для крупных библиотек: он лучше оптимизируется сборщиками в процессе Tree-shaking и нативно поддерживается в React Server Components.

Можно ли строго ограничить допустимые типы children средствами TypeScript?

Статический анализ TypeScript не позволяет гарантировать рантайм-состав поддерева из-за фрагментов, условий и сторонних оберток. Надежный подход — принимать ReactNode и контролировать корректность контекста через Context Guard при рендере подкомпонентов.


Вывод

Отказ от операторов приведения типов as при проектировании составных компонентов делает кодовую базу UI-кита по-настоящему надежной.

Естественное сужение типов через Context Guard, размеченные объединения (Discriminated Unions) и корректная настройка дженериков защищают приложение от регрессий, упрощают навигацию по типам в IDE и обеспечивают предсказуемое масштабирование проекта.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также