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 Set
s 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!