Функция TS для выполнения сужения объединения с учетом значения дискриминантного ключа.

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

В настоящее время у меня есть этот (упрощенный) код, но props внутри операторов if всегда имеет тип WidgetPropsSet:

if (props.kind === "CheckboxInput") {
    console.info("We need to use", props, "here as its narrowed `CheckboxInput` type");
}
if (props.kind === "DropdownInput") {
    console.info("We need to use", props, "here as its narrowed `DropdownInput` type");
}
// ... 16 more

(неупрощенная версия этого кода является шаблоном Svelte, поэтому структура, показанная выше, не может быть изменена)

Я думаю, что мог бы заменить props.kind === "discriminator" внутри оператора if функцией (псевдокод TS):

function narrowWidgetProps(props: WidgetPropsSet, kind: WidgetPropsNames): a-concrete-variant-from-WidgetPropsSet | undefined {
    if (props.kind === kind) return a-concrete-variant-from-WidgetPropsSet;
    else return undefined;
}

Как мне написать эту функцию псевдокода в правильном TypeScript, который может выполнить необходимое сужение?

Для контекста это варианты союза, с которыми я имею дело:

abstract class WidgetProps {
    kind!: string;
}

class CheckboxInput extends WidgetProps {
    checked!: boolean;
    disabled!: boolean;
    // ...
}

class DropdownInput extends WidgetProps {
    entries!: string[];
    selectedIndex!: number | undefined;
    // ...
}

И вот фактическое объединение, которое должно быть в этом формате { value, name }[], чтобы widgetSubTypes можно было передать стороннему коду, которому он нужен в этом формате.

const widgetSubTypes = [
    { value: CheckboxInput, name: "CheckboxInput" },
    { value: DropdownInput, name: "DropdownInput" },
    // ...
] as const;
// Evaluated type:
// const widgetSubTypes: readonly [{
//     readonly value: typeof CheckboxInput;
//     readonly name: "CheckboxInput";
// }, {
//     readonly value: typeof DropdownInput;
//     readonly name: "DropdownInput";
// }, ... 16 more ..., {
//     ...;
// }]

type WidgetPropsSet = InstanceType<typeof widgetSubTypes[number]["value"]>;
// Evaluated type:
// type WidgetPropsSet = CheckboxInput | DropdownInput | ... 16 more

type WidgetPropsNames = typeof widgetSubTypes[number]["name"];
// Evaluated type:
// type WidgetPropsNames = "CheckboxInput" | "DropdownInput" | ... 16 more

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

@jcalz Код, который я предоставил, уже завершен (за исключением одного нерелевантного типа в примере class DropdownInput, который я сейчас изменил на string[]). И я думаю, что это относительно минимально, не жертвуя слишком большим контекстом. Я обновил вопрос ссылкой на TS Playground, где функция, которую я хочу решить, легко редактируется. Спасибо :)

Keavon 11.01.2023 05:27

Я в замешательстве; props не относится к типу размеченных союзов; не должны ли CheckboxInput и DropdownInput иметь строго типизированные kind свойства, как показано здесь? Если нет, то вопрос, кажется, не о дискриминированном союзе.

jcalz 11.01.2023 05:37

Извините за это @jcalz, я обновил ссылку на игровую площадку в основном посте этой важной строкой, которую я забыл, которая теперь правильно демонстрирует упрощенную версию случая, с которым я столкнулся: const props = Math.random() > 0.5 ? checkboxInputInstance : dropdownInputInstance; Это проясняет ваше замешательство?

Keavon 11.01.2023 05:43

Хорошо, теперь это объединение, но это все еще не размеченное объединение, которое является особой структурой данных, которую TS поддерживает для сужения. В вашем союзе нет дискриминационной собственности; kind это просто string. Предположительно, во время выполнения вы убедитесь, что kind можно использовать для различения членов объединения, но это не закодировано в системе типов.

jcalz 11.01.2023 05:46

Если я попытаюсь проигнорировать слова «дискриминационный союз» и буду продвигаться вперед, я получу вот такой подход. Это соответствует вашим потребностям? Если это так, я мог бы написать ответ с объяснением (хотя было бы неплохо, если бы мы заменили «дискриминированный союз» каким-то другим термином). Если нет, то что мне не хватает?

jcalz 11.01.2023 05:46

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

Keavon 11.01.2023 05:55

Давайте продолжим обсуждение в чате.

Keavon 11.01.2023 06:12

Давайте продолжим обсуждение в чате.

jcalz 11.01.2023 22:36
Принципы SOLID в JavaScript с примерами
Принципы SOLID в JavaScript с примерами
Принцип единой ответственности подразумевает то, что:
Типы привязки данных в Angular
Типы привязки данных в Angular
Привязка данных автоматически поддерживает страницу в актуальном состоянии на основе состояния вашего приложения. Вы используете привязку данных,...
3 паттерна TypeScript, которые я использую в своей повседневной работе
3 паттерна TypeScript, которые я использую в своей повседневной работе
В TypeScript 2.0 в язык был добавлен модификатор readonly.
Мифический Angular - Миф Angular: стили компонентов
Мифический Angular - Миф Angular: стили компонентов
Это очень короткая и интересная для меня тема. В Angular каждый компонент может иметь свои собственные прикрепленные стили. Стили могут находиться в...
Подсказка RxJS [filter, skipWhile]
Подсказка RxJS [filter, skipWhile]
Эта подсказка описывает разницу между операторами filter и skipWhile из библиотеки RxJS .
Шаблоны Angular PrimeNg
Шаблоны Angular PrimeNg
Как привнести проверку типов в наши шаблоны Angular, использующие компоненты библиотеки PrimeNg, и настроить их отображение с помощью встроенной...
1
8
56
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

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

class CheckboxInput extends WidgetProps {
    declare checked: boolean;
    declare disabled: boolean;
    declare kind: "CheckboxInput" // <-- literal type
}

class DropdownInput extends WidgetProps {
    declare entries: string[];
    declare selectedIndex: number | undefined;
    declare kind: "DropdownInput" // <-- literal type
}

declare const props: CheckboxInput | DropdownInput;

if (props.kind === "CheckboxInput") {
    props.checked // props is automatically narrowed to CheckboxInput
}

if (props.kind === "DropdownInput") {
    props.entries // props is automatically narrowed to DropdownInput
}

Но если по какой-то причине вы не можете этого сделать и вам нужно оставить тип свойства kind в каждом подклассе, то мы можем написать вашу функцию narrowWidgetProps() следующим образом:

const widgetSubTypes = [
    { value: CheckboxInput, name: "CheckboxInput" },
    { value: DropdownInput, name: "DropdownInput" },
] as const;

type WidgetSubTypes = typeof widgetSubTypes[number];
type WidgetKindMap = { [T in WidgetSubTypes as T["name"]]: InstanceType<T["value"]> };
/* type WidgetKindMap = {
    CheckboxInput: CheckboxInput;
    DropdownInput: DropdownInput;
} */
type WidgetPropsNames = keyof WidgetKindMap
type WidgetPropsSet = WidgetKindMap[WidgetPropsNames]


function narrowWidgetProps<K extends WidgetPropsNames>(props: WidgetPropsSet, kind: K) {
    if (props.kind === kind) return props as WidgetKindMap[K]
    else return undefined;
}

Я использую определение widgetSubTypes для генерации WidgetKindMap, вспомогательного типа, ключами которого являются буквальные значения kind (которые вместе составляют объединение WidgetPropsNames), а значениями являются типы экземпляров соответствующих подклассов WidgetProps (которые вместе составляют WidgetPropsSet союз).

Тогда функция narrowWidgetProps() является общей в K, типом параметра kind. Если это не соответствует свойству kind аргумента props, мы возвращаем undefined. Если он действительно соответствует, то мы утверждаем , что props на самом деле относится к типу WidgetKindMap[K] (индексированный тип доступа означает «тип значения WidgetKindMap, соответствующий ключу типа K»).

Нам нужно это утверждение, as WidgetKindMap[K], потому что у компилятора нет оснований полагать, что сопоставление kind свойств действительно может сузить props до этого типа. Если бы он действительно так думал, то WidgetPropsSet уже был бы дискриминированным союзом, и это усилие было бы ненужным, как упоминалось выше.

В любом случае, давайте проверим это:

declare const props: WidgetPropsSet;

const narrowedCheckboxInput = narrowWidgetProps(props, "CheckboxInput");
if (narrowedCheckboxInput) {
    // const narrowedCheckboxInput: CheckboxInput | undefined
    narrowedCheckboxInput.checked
}

const narrowedDropdownInput = narrowWidgetProps(props, "DropdownInput");
if (narrowedDropdownInput) {
    // const narrowedDropdownInput: DropdownInput | undefined
    narrowedDropdownInput.entries
}

Выглядит неплохо. Тип возвращаемого значения narrowWidgetProps() зависит от аргумента kind так, как вы хотите.

Площадка ссылка на код

Другие вопросы по теме