Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Discriminated union on Opaque using Typescript

Typescript doesn't have a built-in Opaque type like Flow does. So I made my own custom Opaque type:

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

It does the job, but at the same time, I'm losing the ability of Discriminating Union. I'll give an example:

I have an array of animals and the following Opaque:

animals constant

enter image description here

So far so good. Right? well... not exactly. I'm using assertNever which helps me to assert a value as never (useful while discriminating unions):

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

But because of the __TYPE__ property, I can't really "discriminate":

enter image description here

I have made a simple demo on codesandbox that demonstrates the issue, or you can view the full source 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);

A help or a suggestion would be very much appreciated.

like image 779
Eliya Cohen Avatar asked Apr 18 '20 22:04

Eliya Cohen


People also ask

What is discriminated unions in TypeScript?

The concept of discriminated unions is how TypeScript differentiates between those objects and does so in a way that scales extremely well, even with larger sets of objects. As such, we had to create a new ANIMAL_TYPE property on both types that holds a single literal value we can use to check against.

Why are unions discriminated?

Discriminated unions are useful for heterogeneous data; data that can have special cases, including valid and error cases; data that varies in type from one instance to another; and as an alternative for small object hierarchies. In addition, recursive discriminated unions are used to represent tree data structures.

How do you handle a union type in TypeScript?

TypeScript Union Type Narrowing To narrow a variable to a specific type, implement a type guard. Use the typeof operator with the variable name and compare it with the type you expect for the variable.

What is opaque type TypeScript?

An opaque type, in TypeScript, is a type whose true structure is obfuscated to the compiler at compile-time. These types can make your code more type safe, secure, easier to refactor, and faster! While Flow has an opaque keyword for creating opaque types, TypeScript does not; this package is my solution.


1 Answers

I've yet to figure out why your Opaque<T, U> type doesn't properly get narrowed via control flow analysis when using if/else or switch/case statements. When you pass in a primitive datatype for T in Opaque<T, U>, like in Animal with "Cat" | "Dog", you get what's called a "branded primitive", as mentioned in this FAQ entry about making nominal types. What seems to be happening is when you have a branded primitive val and use a regular type guard check against another primitive value somePrimitive, such as if (val === somePrimitive) { /*true*/ } else { /*false*/ } or switch (val) { case somePrimitive: /* true */ break; /*false*/ }, everything is fine in the "true" part of the check: the type of val is narrowed to something like Extract<typeof val, typeof somePrimitive>. So in your case, if (animal === "Dog") { /*true*/ } else { /*false*/ } is narrowed to Opaque<"Dog", "Animal"> in the true branch.

What is not fine is what happens in the "false" part of the check. If val is not equal to somePrimitive, then we should be able to narrow it to Exclude<typeof val, typeof somePrimitive>. That is, when animal is not equal to "Dog", the compiler should narrow animal to Opaque<"Cat", "Animal">. But that's not happening.

Somtimes in checks like this it's correct not to narrow in the false branch. For example, when your types are not singletons and can have more than one valid value of that type. If I had function f(x: string | number) { if (x === "Dog") { /*true*/ } else { /*false* } }, it makes sense to narrow x to string (or even "Dog") in the true branch, but you wouldn't want to narrow x to number in the false branch. The safest thing to do when the compiler doesn't know exactly what's going on is to narrow in the true branch and not to narrow in the false branch.

But I didn't expect to see the compiler taking this route in the case of a branded primitive. It's not possible for the type of animal to be Opaque<"Dog", "Animal"> once you have animal !== "Dog". So I'm inclined to file a GitHub issue about this and see what they say; it feels like either a bug or at least a design limitation. I'm kind of surprised I haven't seen this come up before and that I can't find a directly relevant issue already filed. Oh well.


So, what workarounds are possible? One is to make a user-defined type guard function. User-defined type guards are generally treated by the compiler such that even a false result implies a narrowing of the parameter. This is not always desirable (see microsoft/#15048 for a suggestion to allow such type guard functions to be more configurable so that false returns are not narrowed), but it's what you want here. It could be implemented like this:

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
    }
}

This works. Of course, as you mentioned, it requires a refactoring of all your switch/case statements to function-calling if/else statements, so it could be too painful.


Ideally TypeScript would support a more official opaque/nominal type, such as the unique type brand proposal in microsoft/TypeScript#33038. But for now the simplest workaround I can think of here that lets you keep your switch statements is to use a string enum.

Normally I don't recommend using enums at all, since they have strange caveats and don't conform to the current design methodology of TypeScript (enums are runtime functionality not present in pure JavaScript, running afoul of non-goal #6)... but at least they behave as intended when used as a discriminant:

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
    }
}

Here your Opaque, animals, and Animal are replaced by the single Animal enum. Note that in makeSound we have to test against Animal.DOG and Animal.CAT instead of against "Dog" and "Cat". The compiler still won't do the false-case narrowing otherwise. Luckily checking against the enum values does work.


So, those are my thoughts. Hope they help you proceed. Good luck!

Playground link to code

like image 97
jcalz Avatar answered Sep 28 '22 03:09

jcalz