Гибкие TypeScript Union-типы с автодополнением

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

Объединения (unions) особенно полезны, когда вам нужна гибкость в типах параметров или вы работаете с литеральными значениями. Они отлично подходят, если вы хотите задать общий тип, например string, но при этом предоставить конкретные варианты значений для автодополнения.

Пример с цветами

Допустим, у вас есть функция, принимающая цвет. Можно просто указать тип string, но почему бы не задать конкретные допустимые значения?

type Colors = 'red' | 'blue' | 'yellow';

function paint(color: Colors) {
  console.log(color);
}

paint('red'); // Работает
paint('green'); // Ошибка: аргумент типа '"green"' не соответствует типу 'Colors'.

Это работает хорошо — пока кто-то не попробует передать новый цвет. Такой параметр не будет соответствовать типу Colors. И вот тут на помощь приходят открытые объединения. Что, если мы хотим, чтобы основным типом оставалась string, но с подсказками?

Наивный подход: добавляем string

type Colors = 'red' | 'blue' | 'yellow' | string;
// ^? type Colors = string

К сожалению, TypeScript пока не поддерживает автодополнение в таком случае (см. issue #29729, но обещают в версии 5.3! 🤞). Компилятор просто упрощает объединение до string, так как все литералы наследуются от string.


Решение: string & {}

Лучший способ — использовать пересечение string & {}:

type Colors = 'red' | 'blue' | 'yellow' | (string & {});

function paint(color: Colors) {
  console.log(color);
}

paint('red');    // Работает
paint('green');  // Тоже работает

Это работает! 🎉 Такой подход обеспечивает гибкое автодополнение:

IntelliSense working

Почему это работает?

Чтобы предотвратить "схлопывание" литеральных типов в string, можно использовать базовый тип string в пересечении с {} или Record<never, never>. В глазах компилятора string и string & {} считаются разными, даже если технически они эквивалентны:

type AreTheSame = string extends string & {} ? true : false;
// ^? true

⚠️ Важно:
Пустые пересечения сводятся к never (например, string & number).

Аналогично для number

То же самое можно сделать для числовых типов:

type Gap = 0 | 8 | 16 | (number & {});

function doSomething(gap: Gap) {
  console.log(gap);
}

doSomething(0);     // OK
doSomething(100);   // OK

Источник