0

I have a question about exhaustive switch/case using "never" type.

Say, I have a Set of strings: {A, B} (strings could be arbitrary long words and the Set itself can be very large) And for each subset ( like {}, {A,B}) I would like to create a function: show: Set => string

pseudo-code ahead:

function show(subset: Set<string>): string {
    switch(subset) {
        case Set([]): return "Empty";
        case Set([A]): return "This is A";
        case Set([B]): return "This is B";
        case Set([A, B]): return "Dies ist A und B";
        default: assertUnreachable(subset)
    }
}

function assertUnreachable(x: never): never {
    throw new Error("Didn't expect to get here");
}

Is it somehow possible to guarantee at compile-time that all of the possible subsets are covered in the show function? So that adding C to the Set {A, B, C} would require me to augment the show function? And add cases for {C}, {A, C}, {B, C} and {A, B, C}.

  • 1
    You can't compare `Set`s with `==`, so you can't use them in a `switch` like that. It will always go to the default case. See https://stackoverflow.com/q/31128855/215552 – Heretic Monkey Mar 29 '19 at 13:58
  • Yes, I know that, it's a pitty.. –  Mar 29 '19 at 19:10

1 Answers1

0

In what follows, I will assume the T in our Set<T> type will always be some union of string literal types. And I assume you will be using the --strict compiler option. I will also define these variables and types:

const A = "A"; type A = typeof A;
const B = "B"; type B = typeof B;

Okay, I love this kind of question. There are a few things going on here. First, as mentioned in the comments, you can't check Sets for equality directly. And thus switch/case doesn't apply. Instead, here is a type guard function you can use to see if a particular set of type Set<T> is actually a particular subset of type Set<U> where U extends T.

function isSubset<T extends string, U extends T[]>(
  set: Set<T>, 
  ...subsetArgs: U
): set is Set<U[number]> {
  const otherSet = new Set(subsetArgs);
  if (otherSet.size !== set.size) return false;
  for (let v of otherSet.values()) {
    if (!set.has(v)) return false;
  }
  return true;
}

You can reimplement that however you want, but basically you are checking an element exists in set if and only if it exists in subsetArgs. Here's how you could use it:

declare const set: Set<A | B>; // a set whose elements are either A or B
if isSubset(set) { // no extra args, checking for emptiness
   set; // narrowed to Set<never>
} else if isSubset(set, A) {  // checking for A only
   set; // narrowed to Set<A>
} 

See how the type guard narrows set down in each clause? And you're using if/else instead of switch/case.


The next problem here is that the type Set<A | B> is not automatically expanded to be a union of all possible subsets Set<never> | Set<A> | Set<B> | Set<A | B>. You need to do that yourself:

// note how subset is the expanded union of types
function show(subset: Set<never> | Set<A> | Set<B> | Set<A | B>): string {
  if (isSubset(subset)) {
    return "Empty";
  } else if (isSubset(subset, A)) {
    return "This is A";
  } else if (isSubset(subset, B)) {
    return "This is B";
  } else if (isSubset(subset, A, B)) {        
    return "Something in German I guess";
  } else {
    return assertUnreachable(subset); // no error now
  }
}

console.log(show(new Set([A]))); // okay, prints "This is A"
console.log(show(new Set([A, B]))); // okay, prints "Something in German I guess"
console.log(show(new Set([A, B, "C"]))); // compile time error, "C" unexpected
console.log(show(new Set())); // okay, prints "Empty"

That compiles for you. One possible snag is that the TypeScript compiler sees Set<A> to be a subtype of Set<A | B> (that is, Set<T> is covariant in T). That means the order of your type guard clauses matters... as soon as it decides that subset is not of type Set<A | B>, it will collapse subset to never, which will lead to the opposite problem where the compiler thinks the check was exhaustive when it is not:

function badShow(subset: PowerSetUnion<A | B>): string {
  if (isSubset(subset, A, B)) {
    return "Something in German I guess";
  } else {
    return assertUnreachable(subset); // no error!!! 
  }
}

If you are careful to check from narrowest to widest, you'll be fine. Otherwise you'll really have to do some kind of crazy thing to force the type system to comply:

type InvariantSet<T> = {
  set: Set<T>;
  "**forceInvariant**": (t: T) => T;
}

If you wrap all your Set<T> in InvariantSet<T> objects, then it will prevent this subtyping relationship from happening... the "**forceInvariant**" property is a function with T as both a parameter and the return type. Due to TypeScript's contravariance of function parameters, this will constrain the compiler to treating InvariantSet<T> as neither a subtype nor as a supertype of InvariantSet<U> if U extends T. Now we wrap everything and the issue is resolved:

function isSubset<U extends string[]>(set: any, ...subset: U): set is InvariantSet<U[number]> {
  const otherSet = new Set(subset);
  if (otherSet.size !== set.set.size) return false;
  for (let v of otherSet.values()) {
    if (!set.set.has(v)) return false;
  }
  return true;
}

function show(subset: Set<A | B>): string {
  const s = { set: subset } as 
    InvariantSet<never> | InvariantSet<A> | InvariantSet<B> | InvariantSet<A | B>;
  if (isSubset(s, A, B)) {
    return "Something in German I guess";
  } else if (isSubset(s)) {
    return "Empty";
  } else if (isSubset(s, A)) {
    return "This is A";
  } else if (isSubset(s, B)) {
    return "This is B";
  } else {
    return assertUnreachable(s);
  }
}

function badShow(subset: Set<A | B>): string {
  const s = { set: subset } as
    InvariantSet<never> | InvariantSet<A> | InvariantSet<B> | InvariantSet<A | B>;
  if (isSubset(s, A, B)) {
    return "Something in German I guess";
  } else {
    return assertUnreachable(s); // error as desired!
  }
}

At the cost of greater complexity. So, not sure if that's what you want.


Finally, maybe you also want to be able to take a union type like A | B and have the compiler automatically spit out all the possible subset types like Set<never> | Set<A> | Set<B> | Set<A | B>. That's called a power set.

Well, good news: this is sort of possible. I say "sort of" because the naïve definition of such a thing would be circular, which is not supported. Instead I will unroll the circular definition into a bunch of separate but nearly identical definitions which officially exit if the recursion gets too deep. Meaning it will support unions of up to some fixed number of constituents. That is probably fine, since if your union contains constituents, then the power set will have 2 constituents. I wouldn't want to deal with a union of 512 or 1024 constituents, so I'll exit the recursion at 9 or 10 levels deep. Here it is:

type PowerSetUnion<U, V = U> = Set<U> | (V extends any ? (PSU0<Exclude<U, V>>) : never);
type PSU0<U, V = U> = Set<U> | (V extends any ? (PSU1<Exclude<U, V>>) : never);
type PSU1<U, V = U> = Set<U> | (V extends any ? (PSU2<Exclude<U, V>>) : never);
type PSU2<U, V = U> = Set<U> | (V extends any ? (PSU3<Exclude<U, V>>) : never);
type PSU3<U, V = U> = Set<U> | (V extends any ? (PSU4<Exclude<U, V>>) : never);
type PSU4<U, V = U> = Set<U> | (V extends any ? (PSU5<Exclude<U, V>>) : never);
type PSU5<U, V = U> = Set<U> | (V extends any ? (PSU6<Exclude<U, V>>) : never);
type PSU6<U, V = U> = Set<U> | (V extends any ? (PSU7<Exclude<U, V>>) : never);
type PSU7<U, V = U> = Set<U> | (V extends any ? (PSU8<Exclude<U, V>>) : never);
type PSU8<U, V = U> = Set<U> | (V extends any ? (PSU9<Exclude<U, V>>) : never);
type PSU9<U, V = U> = Set<U> | (V extends any ? (PSUX<Exclude<U, V>>) : never);
type PSUX<U> = Set<U>; // bail out

It's a bunch of distributive conditional types, and I don't know if it's worth explaining it. Let's just see what it does to some concrete examples:

type PowerSetAB = PowerSetUnion<A | B>;
// Set<"A" | "B"> | Set<never> | Set<"A"> | Set<"B">

type PowerSet123 = PowerSetUnion<1 | 2 | 3>;
// Set<never> | Set<1 | 2 | 3> | Set<2 | 3> | Set<3> | Set<2> | Set<1 | 3> | Set<1> | Set<1 | 2>

type PowerSetOhBoy = PowerSetUnion<1 | 2 | 3 | 4 | 5 | 6>;
// Set<never> | Set<1 | 2 | 3 | 4 | 5 | 6> | Set<2 | 3 | 4 | 5 | 6> | 
// Set<3 | 4 | 5 | 6> | Set<4 | 5 | 6> | Set<5 | 6> | Set<6> | Set<5> | 
// Set<4 | 6> | Set<4> | Set<4 | 5> | ... 52 more ... | Set<...>

That works for you, right? Anyway you could change the signature of show() to

function show(subset: PowerSetUnion<A | B>): string;

Or rewrite PowerSetUnion<T> and its subdefinitions to use InvariantSet<T> instead of Set<T> and use that.


Whew! Okay, I hope that made sense and gives you some direction. Good luck!

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Thank you for the response @jcalz! I tried to grokk the code, however, I found It rather difficult. I think I need further explanation, :( I tried to extend the example to 3 types: A|B|C, and I came up with something like this: https://pastebin.com/b0MYnQ6R (Sorry for link to pastebin, but typescript playground link was too long) I do not know how to make show function exhaustive in case of 3 types (A|B|C) using PowerSetUnion. It works fine when I manually expand the set, like here: https://pastebin.com/nSEfvLNK Thank you so very much for your time :) !! –  Apr 06 '19 at 15:06
  • 1
    Oh I think I figured this out. Recall from the beginning of this answer "I assume you will be using the `--strict` compiler option". Make sure you enable `--strict` or at least `--strictFunctionTypes`, since the invariance of `InvariantSet` depends on TypeScript properly enforcing [argument contravariance](https://github.com/Microsoft/TypeScript/wiki/What's-new-in-TypeScript#strict-function-types). Does that fix it? – jcalz Apr 06 '19 at 17:52
  • Yes! That was it. I was missing the --strictFunctionTypes flag, thanks again :-) –  Apr 06 '19 at 18:23
  • the 'only' thing I'm not following is this: set is Set What is 'number' doing there? We haven't mentioned it anywhere, yet without it, it does not work... –  Apr 07 '19 at 07:23
  • 1
    In the case of `isSubset()`, the `U` type parameter corresponds to a tuple of string literals like `["A", "B", "C"]`. That's an array type. To get the actual element types of an array, you can access its numeric indices, or `U[number]` (which would be `"A"|"B"|"C"` in that case). That is, `(Array)[number]` is the same as `T`. – jcalz Apr 07 '19 at 17:29