? Привет! Меня зовут Александр, я работаю фронтенд-разработчиком в компании «МегаФон». Сегодня я хочу поговорить на тему Tagged Unions (размеченных объединений) и объяснить, почему они — ваш секретный инструмент для написания надежного TypeScript-кода.

Улучшение типизации в TypeScript

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

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

Однако, без явного и структурированного управления типами даже в TypeScript могут возникать проблемы. Использование широких типов, недискриминированных объединений (например, string | number или объекты с опциональными свойствами, такими как { propA?: string } | { propB?: number }), может привести к ситуациям, когда компилятор не способен точно определить текущий тип переменной. Это затрудняет безопасный доступ к специфичным свойствам, поскольку доступны только те, которые гарантированно присутствуют во всех составляющих объединения. В таких сценариях разработчикам приходится полагаться на ручные проверки наличия свойств (например, "property" in object) или оператор typeof.

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

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

interface Car {
  move(): void;
  startEngine(): void;
}  

interface Bicycle {
  move(): void;
  gearCount: number;
}

declare function getVehicle(): Car | Bicycle;

let vehicle = getVehicle();

vehicle.move(); // OK, move() - общее свойство для обоих типов

vehicle.startEngine(); // Ошибка компиляции:
// Свойство 'startEngine' отсутствует в типе 'Car | Bicycle'.  

// TypeScript не знает, является ли 'vehicle' машиной или велосипедом без дополнительной проверки.  

if ("startEngine" in vehicle) { vehicle.startEngine(); }
// Этот код будет работать, но мы должны сделать проверку в рантайме.
// К тому же это не гарантирует полноты обработки всех случаев.

В таких случаях TypeScript не даст вам сделать что-то явно недопустимое, но и удобного и надежного способа работать с каждым конкретным типом внутри объединения у него нет.

Именно здесь на сцену выходят Tagged Unions (или дискриминированные объединения). Они представляют собой мощный паттерн, который решает эти проблемы, позволяя создавать типы, представляющие одну из нескольких возможных форм, каждая с отличительным свойством — "тегом" или "дискриминатором". Это не просто добавление еще одного типа или улучшение существующих объединений, это фундаментальный сдвиг в подходе к моделированию данных. Простые объединения только указывают, что значение может быть типом А или Б. Tagged Union добавляет специальную метку (например, kind: 'A' или kind: 'B'), которая позволяет TypeScript точно определить, с каким именно типом он сейчас работает.

Такой подход критически важен для создания сложных систем, где данные могут принимать множество форм в зависимости от контекста, и где требуется высокий уровень предсказуемости и надежности. Это заставляет разработчиков рассматривать данные как набор четких, взаимоисключающих состояний. Такой подход не только улучшает работу с типами, но и ведет к созданию более надежного кода.

Что такое Tagged Unions?

Tagged Union (также известный как дискриминантное объединение, sum type, variant record, choice type или disjoint union) — это тип объединения, где каждый вариант содержит общее свойство-дискриминатор литерального типа. Это свойство позволяет TypeScript точно определять конкретный тип при проверках, гарантируя безопасный доступ к уникальным свойствам и обеспечивая обработку всех возможных случаев.

Рассмотрим классический пример Tagged Union для геометрических фигур:

interface Circle {  
    kind: 'circle'; // Дискриминатор: свойство 'kind' с литеральным типом 'circle'  
    radius: number;  
}  
  
interface Rectangle {  
    kind: 'rectangle'; // Дискриминатор: свойство 'kind' с литеральным типом 'rectangle'  
    width: number;  
    height: number;  
}  
  
type Shape = Circle | Rectangle; // Объединяющий тип, состоящий из Circle и Rectangle  

В этом примере kind является свойством-дискриминатором. Его значение ('circle' или 'rectangle') однозначно указывает на конкретную форму, позволяя TypeScript различать члены объединения.

Механизм работы Tagged Unions основан на автоматическом сужении типов через контрольные точки (type guards). В блоках условной логики (switchif/else) TypeScript анализирует значение дискриминатора и определяет точный тип объекта. Это позволяет компилятору статически верифицировать доступ к уникальным свойствам каждого варианта, полностью исключая ошибки обращения к несуществующим полям.

Рассмотрим функцию, которая вычисляет площадь фигуры:

function getArea(shape: Shape): number {  
    switch (shape.kind) {  
        case 'circle':  
            // Здесь TypeScript точно знает, что 'shape' является типом Circle,  
            // и автоматически предлагает доступ к свойству 'radius'.  
            return Math.PI * shape.radius ** 2;  
        case 'rectangle':  
            // Аналогично, здесь 'shape' сужается до типа Rectangle,  
            // предоставляя безопасный доступ к 'width' и 'height'.  
            return shape.width * shape.height;  
    }  
}  

Этот механизм устраняет необходимость в ручных проверках typeof или in операторах, которые могут быть менее надежными и более многословными.

Дискриминатор преобразует неоднозначное объединение в чётко различимые варианты. Без него TypeScript знает только, что переменная относится к A | B, вынуждая разработчика использовать ненадёжные проверки для доступа к уникальным свойствам. Tagged Unions решают это через общее свойство с уникальным литералом для каждого варианта. При проверке if (obj.kind === 'circle') или case 'circle' TypeScript точно определяет конкретный тип внутри блока, а не предполагает его. Это создаёт явный контракт с системой типов, обеспечивая:

  • исчерпывающую проверку всех вариантов

  • автодополнение в IDE

  • предсказуемость кода для компилятора и разработчика.

Почему Tagged Unions — это мощный паттерн проектирования?

Повышенная безопасность на этапе компиляции

Tagged Unions выявляют сложные логические ошибки на этапе компиляции, а не во время выполнения. Это реализует принцип "Shift-Left": проблемы, которые в(?) TypeScript проявились бы только при тестировании или в продакшене, обнаруживаются сразу при написании кода. Это обеспечивает:

  1. Раннее обнаружение багов - ошибки фиксируются до тестирования

  2. Гарантию полноты проверки - компилятор проверяет обработку всех вариантов данных

  3. Снижение стоимости - исправление на этапе разработки в 10-100 раз дешевле

Таким образом повышается надежность кода и снижаются риски в сложных проектах.

Исчерпывающая проверка (Exhaustiveness сhecking)

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

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "rectangle"; width: number; height: number };

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle": 
      return Math.PI * shape.radius ** 2;
    case "square": 
      return shape.side ** 2;
    case "rectangle": 
      return shape.width * shape.height;
    default:
      // Exhaustiveness сhecking
      // Если TypeScript обнаружит необработанный вариант, 
      // shape здесь будет иметь тип, отличный от never
      const _exhaustiveCheck: never = shape;
      throw new Error(`Unhandled shape: ${_exhaustiveCheck}`);
  }
}

При добавлении нового типа в объединение Typescript выдаст ошибку:

type Shape = 
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number }; // Новый тип!

ERROR: Type 'triangle' is not assignable to type 'never'.

Ошибка указывает, что в switch отсутствует обработка triangle. После обработки нового типа в switch shape в default снова станет типом never и ошибка исчезнет.

Улучшенное сужение типов (Type narrowing)

Tagged Unions позволяют TypeScript автоматически определять конкретный тип внутри условных блоков благодаря свойству-дискриминатору. Это заменяет рискованные ручные проверки (as Type) и делает работу со сложными данными безопасной.

В IDE это даёт точное автодополнение. Без Tagged Unions для объединений (Dog | Cat) редактор показывает только общие свойства. Разработчику приходится вручную проверять типы, что замедляет работу. С Tagged Unions после проверки дискриминатора TypeScript мгновенно распознаёт конкретный тип, и IDE предлагает только релевантные свойства.

Система берёт на себя запоминание структур данных, снижая когнитивную нагрузку. Это ускоряет написание кода, уменьшает ошибки и позволяет сосредоточиться на логике, а не типах.

Чистый код и устойчивость к рефакторингу

Tagged Unions обеспечивают чистоту кода и безопасный рефакторинг через явное описание всех состояний. Явная структура каждого варианта создаёт самодокументирующуюся систему типов, где разработчики сразу видят допустимые состояния и их данные. Это снижает когнитивную нагрузку при чтении кода и ускоряет понимание логики даже спустя месяцы.

При изменении состояний TypeScript автоматически находит необработанные случаи благодаря исчерпывающей проверке. Добавление нового варианта или изменение существующего немедленно подсвечивает места для обновления, предотвращая ошибки до деплоя. Система типов становится активным соавтором: вместо ручного поиска зависимостей компилятор гарантирует, что рефакторинг (переименование, изменение сигнатур) не сломает обработку состояний.

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

Хорошая поддержка IDE

Современные IDE, такие как Visual Studio Code, предоставляют значительно улучшенное автодополнение и проверку ошибок при работе с Tagged Unions. IDE используют информацию о дискриминаторах для точных подсказок свойств и безопасного рефакторинга. Это сокращает количество опечаток и ускоряет разработку за счёт интегрированной проверки типов прямо в редакторе.

Практические cценарии использования Tagged Unions

Управление cостоянием (State Management)

Tagged Unions предоставляют мощный механизм для строгого моделирования состояний в приложениях, особенно когда речь идёт об асинхронных операциях или сложных UI-переходах. Их фундаментальное преимущество заключается в исключении логически невозможных комбинаций состояний на уровне системы типов, что принципиально отличает этот подход от традиционных методов управления состоянием.

Рассмотрим типичный сценарий загрузки данных:

type LoadingState = { status: 'loading' };
type LoadedState<T> = { status: 'loaded'; data: T };
type ErrorState = { status: 'error'; message: string; errorCode?: number };

type UIState<T> = LoadingState | LoadedState<T> | ErrorState;

Каждый вариант объединения содержит явный идентификатор состояния status, который строго определяет связанные данные только для релевантных состояний.

В функции рендеринга TypeScript использует дискриминатор status для точного сужения типа:

function renderUI<T>(state: UIState<T>): React.ReactNode {
  switch (state.status) {
    case 'loading':
      return <Spinner />;

    case 'loaded':
      // TypeScript гарантирует доступ к state.data
      return <DataView content={state.data} />;
      
    case 'error':
      // Доступны ТОЛЬКО свойства ErrorState
      return <ErrorMessage 
               message={state.message} 
               code={state.errorCode} 
             />;
             
    default:
      // Защита от будущих изменений
      const _exhaustiveCheck: never = state;
      throw new Error(`Unhandled status: ${_exhaustiveCheck}`);
  }
}

Ключевые преимущества перед традиционными подходами:

  • Устранение противоречивых состояний. В классовых решениях часто встречаются флаги вроде isLoadingisErrordata, которые могут находиться в противоречивых комбинациях (например, isLoading=true и data=[...]). Tagged Unions делают такие комбинации невозможными на уровне типов

  • Автоматическая валидация данных. При статусе 'loading' компилятор блокирует попытки доступа к state.data или state.message, что обнаруживается сразу при написании кода, а не в рантайме.

  • Синхронизация UI с бизнес-логикой. Добавление нового состояния (например, 'initial') активирует ошибки компиляции во всех местах обработки, гарантируя согласованность логики и интерфейса.

Обработка ответов API

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

type ApiSuccess<T> = {  
    response_type: 'success';  
    data: T;  
};  
  
type ApiError = {  
    response_type: 'error';  
    message: string;  
    statusCode: number;  
};  
  
type ApiResponse<T> = ApiSuccess<T> | ApiError;  
  
async function fetchData<T>(url: string): Promise<ApiResponse<T>> {  
    try {  
        const response = await fetch(url);  
        if (!response.ok) {  
            return {  
                response_type: 'error',  
                message: `Ошибка HTTP: ${response.statusText}`,  
                statusCode: response.status  
            };  
        }  
        const data: T = await response.json();  
        return { response_type: 'success', data };  
    } catch (error: any) {  
        return {  
            response_type: 'error',  
            message: error instanceof Error? error.message : 'Неизвестная ошибка',  
            statusCode: 500  
        };  
    }  
}  


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

type UserData = { id: number; name: string; };

async function processUserResponse() {        
    const result = await fetchData<UserData>('/api/users/1');  
  
    if (result.response_type === 'success') {  
        console.log(`Пользователь: ${result.data.name}`); // TypeScript знает, что result.data существует  
    } else {  
        console.error(`Ошибка при получении пользователя: ${result.message} (Код: ${result.statusCode})`); // TypeScript знает, что result.message и statusCode существуют  
    }  
} 

Такое моделирование исключает ситуации, когда разработчик забывает обработать код ошибки или пытается получить data при ошибке API.

Реализация Option / Result типов

Tagged Unions идеально воплощают функциональные концепции, где отсутствие значения — явное состояние системы.

Рассмотрим реализацию типа Option (аналог Maybe в Haskell) — фундаментального инструмента для обработки nullable-значений без риска ошибок с  undefined/null:

type None = { kind: "none" };
type Some<T> = { kind: "some"; value: T };
type Option<T> = None | Some<T>;

Тип None — явное представление "отсутствия значения" (без null!). Тип Some<T> — контейнер для существующего значения типа T.

Пример:

const getUserName = (user: Option<User>): string => {
  switch (user.kind) {
    case "none":
      return "Гость"; // Используем заглушку когда нет пользователя
    case "some":
      return user.value.name; // TypeScript знает, что user.value существует
  }
};

// Использование:
const loggedInUser: Some<User> = {
    kind: "some",
    value: {
      name: "Иван"
    }
};
const guestUser: None = { kind: "none" };

getUserName(loggedInUser); // "Иван"
getUserName(guestUser);    // "Гость"

Так же рассмотрим реализацию типа Result. Он представляет результат операции, который может быть либо успехом (Success<T>) с данными, либо ошибкой (Failure<E>) с информацией об ошибке. В отличие от исключений, Result делает обработку ошибок явной и типобезопасной.

type Success<T> = { status: 'success'; data: T; };  
type Failure<E> = { status: 'failure'; error: E; }; 
 
type Result<T, E> = Success<T> | Failure<E>; 

// Использование:
function parseNumber(input: string): Result<number, string> {  
    const num = parseInt(input);  
    if (isNaN(num)) {  
        return { status: 'failure', error: `Неверный ввод: "${input}" не является числом.` };  
    }  
    return { status: 'success', data: num };  
}  
  
const parsed = parseNumber('123');  

if (parsed.status === 'success') {  
    console.log(`Парсинг успешен: ${parsed.data}`);  
} else {  
    console.error(`Ошибка парсинга: ${parsed.error}`);  
}

Заключение

Дискриминируемые объединения в TypeScript являются фундаментальным инструментом для разработки надежных, масштабируемых и безопасных приложений. Они предоставляют мощный механизм для моделирования сложных структур данных, которые могут принимать различные, но при этом фиксированные формы, обеспечивая высокую степень типобезопасности и предсказуемости кода.

Как было показано, дискриминируемые объединения значительно улучшают типобезопасность, позволяя компилятору выявлять ошибки на этапе компиляции и гарантируя, что все возможные варианты данных обрабатываются корректно. Они повышают читаемость и поддерживаемость кода за счет явного определения форм данных и упрощения условной логики. Кроме того, такие продвинутые техники, как исчерпывающая проверка с использованием типа never, делают код более надежным, предотвращая пропущенные сценарии.

Внедрение Tagged Unions в вашу повседневную практику разработки на TypeScript — это инвестиция в качество кода, которая многократно окупается снижением затрат на отладку, ускорением рефакторинга и повышением стабильности вашего приложения. Поделитесь своим опытом в комментариях – возможно, у вас есть свои уникальные сценарии использования Tagged Unions!

Комментарии (1)


  1. Dhwtj
    04.08.2025 17:01

    Общий подход — Алгебраические типы данных (ADT).

    Конкретно этот паттерн — Sum Type (тип-сумма).

    Название discriminated union меня раздражает, потому что показывает специфическую для typescript реализацию

    Это комбинация двух идей:

    1. Union: Структура хранит одно из нескольких возможных значений в одной и той же области памяти (как union в C). Либо данные для Circle, либо для Rectangle.

    2. Discriminated (Различаемый): Вместе с данными хранится тег (дискриминант), который однозначно говорит, какой именно вариант там сейчас лежит.

    В Rust-сообществе его так обычно не называют. Используют просто "enum" или "variant" (вариант enum). Термин "discriminated union" больше прижился в языках, где эта функциональность не является нативной частью системы типов и её приходится эмулировать (как в TypeScript или C с ручным тегом).

    enum Shape {
        Circle(f64), // Circle с радиусом
        Rectangle(f64, f64), // Rectangle с шириной и высотой
        Triangle { base: f64, height: f64 }, // Triangle со именованными полями
    }
    
    fn get_area(shape: Shape) -> f64 {
        match shape {
            Shape::Circle(radius) => std::f64::consts::PI * radius * radius,
            Shape::Rectangle(width, height) => width * height,
            Shape::Triangle {base, height} => 0.5 * base * height,
        }
    }

    Механизм работы Tagged Unions основан на автоматическом сужении типов через контрольные точки (type guards). В блоках условной логики (switchif/else) TypeScript анализирует значение дискриминатора и определяет точный тип объекта. Это позволяет компилятору статически верифицировать доступ к уникальным свойствам каждого варианта, полностью исключая ошибки обращения к несуществующим полям.

    Конкретно вот эта фраза не понятна.