Дискриминационный союз по непрозрачности с использованием Typescript - PullRequest
6 голосов
/ 19 апреля 2020

Typescript не имеет встроенного Opaque типа , как Flow имеет . Поэтому я сделал свой собственный Opaque тип:

type Opaque<Type, Token = unknown> = Type & {readonly __TYPE__: Token}

Он выполняет свою работу, но в то же время я теряю способность Отличительный союз . Я приведу пример:

У меня есть массив животных и следующий опак:

animals constant

enter image description here

Пока все хорошо. Правильно? ну ... не совсем Я использую assertNever , который помогает мне установить значение как never (полезно при различении объединений):

export function assertNever(value: never): never {
    throw new Error(`Unhandled discriminated union member: ${JSON.stringify(value)}`);
}

Но из-за свойства __TYPE__ я могу ' на самом деле "разграничить":

enter image description here

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

export function assertNever(value: never): never {
    throw new Error(`Unhandled discriminated union member: ${JSON.stringify(value)}`);
}

type Opaque<Type, Token = unknown> = Type & { readonly __TYPE__: Token };

const animals = ["Dog" as const, "Cat" as const];

type Animal = Opaque<typeof animals[0], "Animal">;


function makeSound(animal: Animal) {
    switch (animal) {
        case "Dog":
            return "Haw!";
        case "Cat":
            return "Meow";
        default:
            assertNever(animal);
            // ^^^ discriminated unions won't work.
    }
}

makeSound("Dog" as Animal);

Помощь или предложение будет очень цениться.

1 Ответ

1 голос
/ 19 апреля 2020

Мне еще предстоит выяснить, почему ваш тип Opaque<T, U> неправильно сужается с помощью анализа потока управления при использовании операторов if / else или switch / case. Когда вы передаете примитивный тип данных для T в Opaque<T, U>, как в Animal с "Cat" | "Dog", вы получаете то, что называется "фирменным примитивом", как упомянуто в этой записи FAQ о создании номинальных типов . Кажется, что происходит, когда у вас есть фирменный примитив val и вы используете обычную проверку типа для другого примитива somePrimitive, такого как if (val === somePrimitive) { /*true*/ } else { /*false*/ } или switch (val) { case somePrimitive: /* true */ break; /*false*/}, в «истинной» части все в порядке проверки: тип val сужается до чего-то вроде Extract<typeof val, typeof somePrimitive>. Так что в вашем случае if (animal === "Dog") { /*true*/ } else { /*false*/ } сужается до Opaque<"Dog", "Animal"> в истинной ветви.

То, что не отлично, это то, что происходит в «ложной» части проверки. Если val равно , а не равно somePrimitive, тогда мы сможем сузить его до Exclude<typeof val, typeof somePrimitive>. То есть, когда animal не равно "Dog", компилятор должен сузить animal до Opaque<"Cat", "Animal">. Но этого не происходит.

Иногда в таких проверках правильно не сужаться в ложной ветви. Например, когда ваши типы не являются синглетонами и могут иметь более одного допустимого значения этого типа. Если бы у меня было function f(x: string | number) { if (x === "Dog") { /*true*/ } else { /*false* } }, имеет смысл сузить x до string (или даже "Dog") в истинной ветви, но вы бы не хотели сужать x до number в ложной ветви , Самое безопасное, что нужно сделать, когда компилятор не знает точно, что происходит, - это сузить в истинной ветви и не сузить в ложной ветви.

Но я не ожидал, что компилятор примет это маршрут в случае фирменного примитива. Тип animal не может быть Opaque<"Dog", "Animal">, если у вас есть animal !== "Dog". Так что я склонен подать проблему GitHub по этому поводу и посмотреть, что они говорят; Это похоже на ошибку или, по крайней мере, ограничение дизайна. Я немного удивлен, что не встречал такого ранее, и что не могу найти прямо соответствующую проблему, уже поданную. Ну хорошо.


Итак, какие обходные пути возможны? Одним из них является создание определяемой пользователем функции защиты типа . Определяемые пользователем средства защиты типов обычно обрабатываются компилятором так, что даже результат false подразумевает сужение параметра. Это не всегда желательно (см. microsoft / # 15048 для предложения о том, чтобы такие функции защиты типов были более конфигурируемыми, чтобы возврат false не был сужен), но это то, что вам нужно здесь. Это может быть реализовано так:

function is<T extends string>(x: any, v: T): x is T {
    return x === v;
}
function makeSound(animal: Animal) {
    if (is(animal, "Dog")) {
        return "Haw!";
    } else if (is(animal, "Cat")) {
        return "Meow"!
    } else {
        assertNever(animal); // no error now
    }
}

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


В идеале TypeScript должен поддерживать более официальный непрозрачный / номинальный тип, такой как предложение бренда типа unique в microsoft / TypeScript # 33038 . Но пока самый простой обходной путь, который я могу здесь представить, который позволяет вам сохранить ваши операторы switch, состоит в использовании string enum .

Обычно я вообще не рекомендую использовать перечисления, поскольку они имеют странные предостережения и не соответствуют текущей методологии разработки TypeScript (перечисления - это функциональные возможности времени выполнения, отсутствующие в чистом JavaScript, идущий вразрез с нецелевой # 6) ... но, по крайней мере, они ведут себя как положено, когда используются в качестве дискриминанта:

enum Animal {
    DOG = "Dog",
    CAT = "Cat"
}

function makeSound(animal: Animal) {
    switch (animal) {
        case Animal.DOG:
            return "Woof!"; // English-speaking dog 
        case Animal.CAT:
            return "Meow!";
        default:
            assertNever(animal); // no error
    }
}

Здесь ваши Opaque, animals и Animal заменяются одним Animal перечислением. Обратите внимание, что в makeSound мы должны тестировать против Animal.DOG и Animal.CAT вместо "Dog" и "Cat". В противном случае компилятор по-прежнему не будет сужать ложный случай. К счастью, проверка значений enum работает .


Итак, это мои мысли. Надеюсь, они помогут вам продолжить. Удачи!

Детская площадка ссылка на код

...