Дискриминированные объединения TypeScript: безопасное сужение и проверки

Дискриминированные объединения TypeScript: безопасное сужение и проверки

Предпосылки: Для примеров кода требуется TypeScript 4.0 или новее, а также tsconfig.json с "target": "ES2016" или выше и "strict": true. FormState псевдоним рекурсивного типа требует TypeScript 3.7+.

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

Оглавление

Проблема с логическими флагами и необязательными полями

Плоский предмет, который вам лжет

Рассмотрим распространенный шаблон: объект ответа в форме { success: boolean; data?: string[] }где data существует только тогда, когда success является true. TypeScript не может обеспечить соблюдение этих отношений. Система типов видит data как string[] | undefined независимо от стоимости successпотому что оба поля принадлежат одному интерфейсу. Ничто не мешает вызывающему объекту создать { success: true } без dataили { success: false, data: ['oops'] }.

interface ApiResponse {
  success: boolean;
  data?: string[];
}

function processResponse(res: ApiResponse) {
  if (res.success) {
    
    
    console.log(res.data!.join(', '));
  }
}


try {
  processResponse({ success: true }); 
} catch (e) {
  console.error('Caught expected crash:', (e as Error).message);
}

Ненулевое утверждение скрывает реальную проблему. Тип допускает состояние, которое бизнес-логика считает невозможным, и компилятор не может помочь.

Почему if (obj.b) Не сужает необязательные поля

Сужение потока управления в TypeScript работает с типом проверяемого свойства, а не с формой окружающего объекта. Если типом является один интерфейс, а не объединение, проверка if (res.success) сужается res.success от boolean к true внутри этой ветки, но это не меняет тип res сам. Форма объекта остается ApiResponseи data остается string[] | undefined. Чтобы сузить форму объекта, его тип должен в первую очередь быть объединением различных типов. Без этого объединения компилятору не к чему сужаться.

Дискриминированные профсоюзы: основная модель

Замена флага буквальными дискриминантами

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

type ApiResponse =
  | { success: true; data: string[] }
  | { success: false };

function processResponse(res: ApiResponse) {
  switch (res.success) {
    case true:
      
      console.log(res.data.join(', ')); 
      break;
    case false:
      
      console.log('Request failed');
      break;
  }
}


Компилятор полностью отвергает невозможное состояние. Если success является true, data должен присутствовать. Если success является falseпредоставление data вызывает ошибку типа.

Создание члена союза из логической переменной

Другая проблема построения возникает, когда член объединения создается из среды выполнения. boolean. Переменная типа boolean это союз true | falseи объект { b: flag, c: value } должен соответствовать одному члену Result ниже. До TypeScript 3.5 компилятор отклонял это назначение (я подтвердил ошибку в 3.4.5). Начиная с версии 3.5, он принимает его, когда дискриминант является boolean или другой небольшой союз литералов, поэтому на текущем компиляторе компилируется код в комментарии ниже.

Такое принятие является удобством, а не гарантией того, что объект действителен. Принятый объект все еще может нести c когда b является false. Создание каждого варианта в отдельной ветке обеспечивает честность данных и работает на каждой версии. Если вы поддерживаете старые компиляторы, вы также увидите старую ошибку в существующих ответах на переполнение стека.

type Result =
  | { b: true; c: number }
  | { b: false };







function makeResultFixed(flag: boolean, value: number): Result {
  if (flag) {
    return { b: true, c: value };
  } else {
    return { b: false };
  }
}


function createResult<B extends true | false>(
  b: B,
  ...args: B extends true ? [c: number] : []
): Result {
  if (b) {
    const c = (args as [number])[0];
    if (typeof c !== 'number') {
      throw new Error('createResult: c is required when b is true');
    }
    return { b: true, c };
  }
  return { b: false };
}

Первый вариант собирает каждый вариант в отдельной ветке. Каждая ветвь возвращает объектный литерал, который соответствует ровно одному члену объединения, поэтому c существует только тогда, когда b является true. Второй вариант использует параметр универсального типа, ограниченный true | falseпозволяя компилятору разрешить правильную ветвь на основе типа литерала аргумента на каждом сайте вызова. Защита во время выполнения защищает от того, чтобы вызывающие абоненты проходили расширенную boolean значение, которое может обойти гарантию типа условного отдыха во время компиляции ( boolean-типизированный аргумент определяет тип остального [c: number] | []).

Сужение на практике: switch, inи поток управления

switch Утверждения и буквальные дискриминанты

switch по дискриминантному свойству сужает объединение ровно до одного варианта на каждый case блокировать. Каждый case ветка видит только поля, определенные для этого варианта. Пока if/else if цепи работают одинаково для целей сужения, switch является традиционным выбором для тегированных объединений, поскольку он отображает одну ветвь для каждого варианта и естественным образом интегрируется с проверкой полноты через default пункт.

in Оператор как альтернативный дискриминант

Если уникальное свойство существует только в одном варианте, in оператор может сузить область без специального дискриминантного поля. Проверка if ('data' in obj) сужается до варианта, содержащего data. Однако этот подход менее явный, его сложнее систематически исчерпать, и он не работает, когда два варианта имеют одно и то же имя свойства, поскольку in проверка уже не может их различать. Используйте его для дополнения, а не замены буквальных дискриминантов.

type CSVFieldResult =
  | { kind: 'number'; value: number }
  | { kind: 'nan'; raw: string };

function parseCSVField(raw: string): CSVFieldResult {
  
  const parsed = Number(raw);
  if (Number.isNaN(parsed)) {
    return { kind: 'nan', raw };
  }
  return { kind: 'number', value: parsed };
}





const result = parseCSVField('42abc');
if (result.kind === 'number') {
  console.log(result.value);
} else {
  console.log('Not a number:', result.raw);
}

Этот пример демонстрирует сужение распознаваемого объединения за пределами шаблонов конфигурации объекта. Он использует Number() скорее, чем parseInt правильно отклонять строки с конечными нечисловыми символами ('42abc' → NaN) и сохранить десятичные значения ('3.14' → 3.14). Дискриминированный тип возвращаемого значения заставляет вызывающую сторону явно обрабатывать оба результата.

Полная проверка с помощью never Тип

Что такое полнота и почему это важно

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

assertNever Шаблон

Техника канонической исчерпываемости ставит assertNever функционировать в default филиал switch. Если каждый вариант обработан, оставшийся введите в default является never. Если вариант пропущен, тип отсутствует. neverи задание на never параметр терпит неудачу во время компиляции. В чистом коде TypeScript эта строка недоступна во время выполнения; он защищает от вызывающих вызовов JavaScript или утверждений небезопасного типа, которые обходят проверку во время компиляции.

export function assertNever(x: never): never {
  let description: string;
  try {
    description = JSON.stringify(x);
  } catch {
    description = String(x);
  }
  throw new Error(`Unexpected value: ${description}`);
}

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number }
  | { kind: 'triangle'; base: number; height: number }; 

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle': return Math.PI * (shape.radius ** 2);
    case 'square': return shape.side ** 2;
    
    default: return assertNever(shape);
    
    
  }
}

Ошибка появляется в момент компиляции кода, до запуска любого теста. Добавление case 'triangle' разрешает ее и восстанавливает полноту.

Построение многошагового конечного автомата формы

Определение государственного союза

А FormState введите с помощью kind дискриминант моделирует каждый шаг как отдельный вариант. Каждый вариант содержит только данные, относящиеся к этому шагу, что делает невозможные комбинации непредставимыми. FormState type использует псевдоним рекурсивного типа (поддерживается начиная с TypeScript 3.7), чтобы разрешить error вариант для ссылки FormState сам.

type FormState =
  | { kind: 'step1'; name: string; email: string }
  | {
      kind: 'step2';
      step1Data: { name: string; email: string };
      address: string;
      city: string;
    }
  | {
      kind: 'submitting';
      step1Data: { name: string; email: string };
      step2Data: { address: string; city: string };
    }
  | {
      kind: 'done';
      confirmationId: string;
    }
  | {
      kind: 'error';
      message: string;
      lastState: Exclude<FormState, { kind: 'error' }>;
    };

step2 вариант включает проверенный step1Dataгарантировать, что достижение второго шага без завершения первого структурно невозможно. error вариант несет lastState для логики повтора, но исключает вложенные ошибки через Exclude.

Определение действий как дискриминируемого союза

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

type FormAction =
  | { type: 'NEXT_STEP1'; name: string; email: string }
  | { type: 'NEXT_STEP2'; address: string; city: string }
  | { type: 'SUBMIT' }
  | { type: 'SUBMIT_SUCCESS'; confirmationId: string }
  | { type: 'SUBMIT_FAILURE'; message: string }
  | { type: 'RETRY' }
  | { type: 'RESET' };

Исчерпывающий редуктор

Редуктор использует вложенные switch заявления. Внешний переключатель включается state.kindвнутренний на action.type где несколько действий применяются к одному состоянию. Оба уровня используют assertNever чтобы гарантировать полноту.

Каждый внутренний switch необходимо перечислить каждого члена FormActionлибо как управляемый переход, либо как явный отказ от операции, для default ветка, чтобы увидеть never. Эта многословность — цена шаблона, и она окупается: добавление нового типа действия в FormAction выдает ошибки компиляции в каждом внутреннем переключателе, в котором он не указан.

import { assertNever } from './shared/assertNever';

const INITIAL_STATE: FormState = { kind: 'step1', name: '', email: '' };

function formReducer(state: FormState, action: FormAction): FormState {
  switch (state.kind) {
    case 'step1':
      switch (action.type) {
        case 'NEXT_STEP1':
          return {
            kind: 'step2',
            step1Data: { name: action.name, email: action.email },
            address: '',
            city: '',
          };
        case 'RESET': return INITIAL_STATE;
        case 'NEXT_STEP2': case 'SUBMIT': case 'SUBMIT_SUCCESS': case 'SUBMIT_FAILURE': case 'RETRY':
          return state; 
        default: return assertNever(action);
      }

    case 'step2':
      switch (action.type) {
        case 'NEXT_STEP2':
          return {
            kind: 'step2',
            step1Data: state.step1Data,
            address: action.address,
            city: action.city,
          };
        case 'SUBMIT':
          return {
            kind: 'submitting',
            step1Data: state.step1Data,
            step2Data: { address: state.address, city: state.city },
          };
        case 'RESET': return INITIAL_STATE;
        case 'NEXT_STEP1': case 'SUBMIT_SUCCESS': case 'SUBMIT_FAILURE': case 'RETRY':
          return state;
        default: return assertNever(action);
      }

    case 'submitting':
      switch (action.type) {
        case 'SUBMIT_SUCCESS': return { kind: 'done', confirmationId: action.confirmationId };
        case 'SUBMIT_FAILURE': return { kind: 'error', message: action.message, lastState: state };
        case 'NEXT_STEP1': case 'NEXT_STEP2': case 'SUBMIT': case 'RETRY': case 'RESET':
          return state;
        default: return assertNever(action);
      }

    case 'done':
      switch (action.type) {
        case 'RESET': return INITIAL_STATE;
        case 'NEXT_STEP1': case 'NEXT_STEP2': case 'SUBMIT': case 'SUBMIT_SUCCESS': case 'SUBMIT_FAILURE': case 'RETRY':
          return state;
        default: return assertNever(action);
      }

    case 'error':
      switch (action.type) {
        case 'RETRY': return state.lastState;
        case 'RESET': return INITIAL_STATE;
        case 'NEXT_STEP1': case 'NEXT_STEP2': case 'SUBMIT': case 'SUBMIT_SUCCESS': case 'SUBMIT_FAILURE':
          return state;
        default: return assertNever(action);
      }

    default: return assertNever(state);
  }
}

Почему это лучше подхода с логическими флагами

Плоская альтернатива, например { step: number; isSubmitting: boolean; error?: string; data?: object } разрешающие состояния, которые никогда не должны существовать: isSubmitting: true с error определены одновременно, step: 3 без данных из первого или второго шагов, или error населено пока isSubmitting останки true. При использовании плоского типа вы пишете такие охранники, как if (state.isSubmitting && !state.error) на каждом месте потребления. Дискриминированный профсоюз структурно устраняет этих охранников. Добавление шестого состояния к триггерам объединения приводит к ошибкам компиляции везде, где редуктор, и любой потребляющий код не может их обработать.

Сравнение: ?: never против дискриминируемых профсоюзов

Критерий ?: never Шаблон Дискриминированный Союз
Сужение эргономики Требует in проверки или предикаты типов; нет ни одного свойства для включения switch (obj.kind) сужается мгновенно
Ошибки полноты Нет встроенного механизма; требуются ручные проверки assertNever в default выдает ошибки времени компиляции
Необоснованные ковариантные записи Потенциально необоснованно: структурное подтипирование может позволить переменной более широкого типа обойти ограничение эксклюзивного поля; вам все равно следует проверять во время выполнения Каждый вариант представляет собой отдельный тип; ковариантная запись не объединяет фигуры молча
Требуется явный дискриминант? Нет. Полагается на структурное отсутствие свойств. Да. Требуется общее свойство-литерал во всех вариантах.
Лучший вариант использования Небольшие двухвариантные объекты, в которых добавление kind поле непрактично Три или более вариантов, конечные автоматы, редукторы, любой путь кода, требующий полноты

?: never шаблон работает, помечая свойство как необязательное с помощью типа neverчто предотвращает любое не-undefined значение от присвоения; undefined остается назначаемым, если только exactOptionalPropertyTypes: true установлен в tsconfig.json. Он полезен для облегченных ограничений исключительных полей, но не дает никаких гарантий сужения или исчерпываемости, которые предлагают дискриминируемые объединения.

Практические рекомендации и подводные камни

Когда обращаться к дискриминируемым профсоюзам

Дискриминированные профсоюзы хорошо вписываются в следующие сценарии:

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

Двухвариантные объединения также получают исчерпывающую полноту и сужение во время компиляции из-за шаблона, поскольку ApiResponse приведенный выше пример демонстрирует.

Распространенные ошибки

С использованием string вместо строкового литерала дискриминант полностью сужается. С kind: string по каждому варианту, switch (x.kind) не сужает объединение, поэтому чтение поля только для вариантов, такого как x.a внутри case 'a' происходит сбой с ошибкой TS2339 (свойство в объединении не существует).

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

Вложенность объединений более чем на один уровень (размечаемый союз внутри другого размеченного объединения) усложняет сужение и затрудняет чтение редьюсеров. Предпочитайте плоские дискриминанты с описательными kind такие ценности, как 'step2-loading' над вложенными структурами.

Следующие шаги

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

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



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *