Search code examples
typescriptdiscriminated-union

What is the shortest type-safe way to compare objects from the same discriminated union?


Let's assume the existence of the following discriminated union type:

interface Circle {
  type: 'circle';
  radius: number;
}

interface Square {
  type: 'square';
  sideLength: number;
}

type Shape = Circle | Square;

I am trying to find the easiest way to implement a type-safe comparison function (i.e. without any casts or potential runtime errors), which checks if 2 objects of this union type are equivalent.

The reason why I ask is because the TS checker does NOT seem narrow the objects' types to the same concrete type when you verify that their discriminant property is the same, i.e.:

function areEqual(shapeA: Shape, shapeB: Shape): boolean {
  if (shapeA.type !== shapeB.type) {
    return false;
  }
   
  switch (shapeA.type) {
    case ('circle'):
      return shapeA.radius === shapeB.radius; // <- TS complains that shapeB might NOT have 'radius' property here, even though the if above guarantees that shapeA and shapeB's types are the same
    case ('square'):
    ....
  }

Is there any way to avoid this error?

NOTE: I understand that I can preserve type-safety by checking shapeB's type with yet another inner switch but this would require a lot of unnecessary code just to appease the type checker, especially if the union has more than 2 types.


Solution

  • TypeScript's control flow analysis is what allows the compiler to narrow the type of variables and properties based on checks. But this analysis is built from a set of heuristic rules that only trigger in certain specific situations.

    It does not perform a full "what-if" analysis whereby every expression of a union type is hypothetically narrowed to every possible union member. For example, inside the body of areEqual(), the compiler does not consider all of the following situations

    • What should the types be if shapeA is a Circle and shapeB is a Circle?
    • What should the types be if shapeA is a Circle and shapeB is a Square?
    • What should the types be if shapeA is a Square and shapeB is a Circle?
    • What should the types be if shapeA is a Square and shapeB is a Square?

    If it did this, the compiler would surely be able to see that your implementation is safe. But if it did things like this, then most non-trivial programs would probably take longer to compile than you'd be willing to wait (to put it mildly). There just aren't enough resources to do a brute force analysis. At one point I wished for some way to selectively opt into such analysis in limited situations (see microsoft/TypeScript#25051) but no such feature exists in the language. So brute force analysis is out.

    The compiler doesn't have human intelligence (as of TS4.6 anyway) so it can't figure out how to abstract its analysis to a higher order. As a human being, I can understand that once we establish (shapeA.type === shapeB.type), it "ties together" shapeA and shapeB such that any subsequent check of either variable's type property should narrow both variables. But the compiler does not understand this.

    It only has a set of heuristics for specific situations. For discriminated unions, if you want narrowing, you need to check the discriminant property against particular literal type constants.

    There is no built-in support for your areEqual() scenario, most likely because it doesn't come up enough to be worth hardcoding.


    So what can you do? Well, TypeScript does give you the ability to write your own user-defined type guard functions which allow you some more fine grained control over how narrowing occurs. But using it requires some nontrivial refactoring of your code. For example:

    function areEqual(...shapes: [Shape, Shape]): boolean {
    
      if (!hasSameType(shapes)) return false;
    
      if (hasType(shapes, "circle")) {
        return shapes[0].radius === shapes[1].radius;
      } else if (hasType(shapes, "square")) {
        return shapes[0].sideLength === shapes[1].sideLength;
      }
    
      assertNever(shapes); 
    }
    

    Here we are packaging the shapeA and shapeB parameters into a single shapes rest argument of the [Shape, Shape] tuple type. We need to do that because user-defined type guard functions only act on a single argument, so if we want both objects to be narrowed at once, it forces us to create a single value where that happens.

    type SameShapeTuple<T extends Shape[], U extends Shape = Shape> =
      Extract<U extends Shape ? { [K in keyof T]: U } : never, T>;
    
    function hasSameType<T extends Shape[]>(shapes: T): shapes is SameShapeTuple<T> {
      return shapes.every(s => s.type === shapes[0].type);
    }
    

    SameShapeTuple<T> is a helper type that takes a Shape array/tuple type and distributes the Shape union across the array type. So SameShapeTuple<Shape[]> is Circle[] | Square[] and SameShapeTuple<[Shape, Shape, Shape]> is [Circle, Circle, Circle] | [Square, Square, Square]. And hasSameType() takes an array of shapes of type T and returns shapes is SameShapeTuple<T>. Inside areEqual(), we are using hasSameType() to narrow [Shape, Shape] to [Circle, Circle] | [Square, Square].

    function hasType<T extends SameShapeTuple<Shape[]>, K extends T[number]['type']>(
      shapes: T, type: K
    ): shapes is Extract<T, { type: K }[]> {
      return shapes[0]?.type === type;
    }
    

    The hasType(shapes, type) function is a type guard that will narrow a union-typed shapes array to whichever member of the union has elements whose type property matches type. Inside areEqual(), we are using hasType() to narrow [Circle, Circle] | [Square, Square] to either [Circle, Circle] or [Square, Square] or even never depending the type parameter passed to it.

    function assertNever(x: never): never {
      throw new Error("Expected unreachable, but got a value: " + String(x));
    }
    

    And finally, because you need to use if/else blocks instead of switch statements for user-defined type guard functions, we have assertNever(), which acts as an exhaustiveness check to make sure that the compiler agrees that it's not really possible to fall off the end of the function (see microsoft/TypeScript#21985 for more info).

    All of this works with no error. Whether or not it's worth the complexity of refactoring is up to you.


    Note that you don't have to make these Shape-specific. You could abstract the type guard functions so that you also pass in the name of the discriminant key and it will work for any discriminated union. It could look like this:

    type SameDiscUnionMemberTuple<T extends any[], U extends T[number] = T[number]> =
      Extract<U extends unknown ? { [K in keyof T]: U } : never, T>;
    
    function hasSameType<T extends object[], K extends keyof T[number]>(shapes: T, typeProp: K):
      shapes is SameDiscUnionMemberTuple<T> {
      const sh: T[number][] = shapes;
      return sh.every(s => s[typeProp] === sh[0][typeProp]);
    }
    
    function hasType<T extends object[], K extends keyof T[number], V extends (string | number) & T[number][K]>(
      shapes: T, typeProp: K, typeVal: V): shapes is Extract<T, Record<K, V>[]> {
      const sh: T[number][] = shapes;
      return sh[0][typeProp] === typeVal;
    }
    function areEqual(...shapes: [Shape, Shape]): boolean {
    
      if (!hasSameType(shapes, "type")) return false;
    
      if (hasType(shapes, "type", "circle")) {
        return shapes[0].radius === shapes[1].radius;
      } else if (hasType(shapes, "type", "square")) {
        return shapes[0].sideLength === shapes[1].sideLength;
      }
    
      assertNever(shapes);
    }
    

    I'm not going to go over that in detail because this answer is long enough as it is.

    Playground link to code