Search code examples
typescripttypescript-typingsdiscriminated-union

When to use Discriminated Unions vs Classes implementing an interface


I have code like this:

interface Node{
    value: number;
}

class Parent implements Node{
    children: Node[] = [];
    value: number = 0;
}


class Leaf implements Node{
    value: number = 0;
}


function test(a:Node) : void {
  if(a instanceof Leaf)
    console.log("Leaf")
  else 
    console.log("Parent")
}

Looking at https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes-func.html#discriminated-unions, it seems like a different way of achieving this would be

type Parent = {kind: "Parent", value:number, children:(A|B)[]}
type Leaf = {kind: "Leaf", value:number}

type Node = Parent | Leaf

function test(a:Node) :void {
  if(a.kind === "Leaf")
    console.log("yes")
  else 
    console.log("no")
}

Now, I'm confused as to what to use. All other languages I have used until now only had one of those options - here is typescript with both. Besides approach 1 having a constructor, while approach 2 is completely transpiled away, what are the real differences here? Which method is preferably seen in codebase?

The functionality, as you can see, is so that we can easily traverse a tree. Of course I could also just have only one type/class and set the children to [] there, but then the question of type vs class is repeated. And I was told that having different classes was more performance friendly.


Solution

  • The key difference between discriminated unions and interfaces is that interfaces are open for extension, but unions are not.

    That is, given an interface, anyone can add a new implementation of that interface. In contrast, adding additional types to a union requires changing that union. Therefore, a union is superior if the set of possible types is known up front, while an interface allows extending the set of possible types later.

    As for the choice between classes and interfaces, classes have a prototype, which allows them to inherit behavior (or even state), but makes the type harder to send over the network (JSON has no notion of prototypes ...). So classes are favored if you benefit from prototypes, while interfaces (or unions) are preferred for exchanging data with other processes.

    All of that is a red herring in your case, though, because I don't see any benefit in representing the different nodes in a tree with different types. You have no difference in behavior, and the difference in data is easiest modeled by an empty list. That is, I'd simply do:

    interface Node {
      value: number;
      children: node[];
    }
    

    This makes code a lot simpler. Suppose you were to add a new child to a leaf. In your case, you'd need to do something like this:

    addChild(value: number) {
      const leaf = new Leaf();
      leaf.value = value;
    
      const newSelf = new Parent();
      newSelf.value = this.value;
      newSelf.children = [leaf];
    
      this.parent.children = this.parent.children.map(child => child == this ? newSelf : child);
    }
    

    whereas I would simply do:

    addChild(value: number) {
      this.children.push({
        value, 
        children: []
      });
    }
    

    And I was told that having different classes was more performance friendly.

    The difference is very very small (say, about 0.000000001 seconds). You probably have more pressing concerns.