typescriptconstructorabstract-classsealed-classexhaustive

How to create a sealed abstract class in TypeScript?


In Kotlin, a sealed class is an abstract class whose direct subclasses are known at compile time. All the direct subclasses of the sealed class must be defined in the same module as the sealed class. No class defined in any other module can extend the sealed class. This allows the Kotlin compiler to perform exhaustiveness checks on the sealed class, just as the TypeScript compiler does with unions. I want to know whether it's possible to implement something similar in TypeScript.

Consider this abstract class, Expr, and its direct subclasses, Num and Add.

abstract class Expr<A> {
  public abstract eval(): A;
}

class Num extends Expr<number> {
  public constructor(public num: number) {
    super();
  }

  public override eval() {
    return this.num;
  }
}

class Add extends Expr<number> {
  public constructor(public left: Expr<number>, public right: Expr<number>) {
    super();
  }

  public override eval() {
    return this.left.eval() + this.right.eval();
  }
}

Here's an example instance of the Expr class.

// (1 + ((2 + 3) + 4)) + 5
const expr: Expr<number> = new Add(
  new Add(new Num(1), new Add(new Add(new Num(2), new Num(3)), new Num(4))),
  new Num(5)
);

I want to transform this instance into a right associated expression.

// 1 + (2 + (3 + (4 + 5)))
const expr: Expr<number> = new Add(
  new Num(1),
  new Add(new Num(2), new Add(new Num(3), new Add(new Num(4), new Num(5))))
);

Hence, I added a rightAssoc abstract method to the Expr class.

abstract class Expr<A> {
  public abstract eval(): A;

  public abstract rightAssoc(): Expr<A>;
}

And implemented this method in both the Num and Add subclasses.

class Num extends Expr<number> {
  public constructor(public num: number) {
    super();
  }

  public override eval() {
    return this.num;
  }

  public override rightAssoc(): Num {
    return new Num(this.num);
  }
}

class Add extends Expr<number> {
  public constructor(public left: Expr<number>, public right: Expr<number>) {
    super();
  }

  public override eval() {
    return this.left.eval() + this.right.eval();
  }

  public override rightAssoc(): Add {
    const expr = this.left.rightAssoc();
    if (expr instanceof Num) return new Add(expr, this.right.rightAssoc());
    if (expr instanceof Add) {
      return new Add(expr.left, new Add(expr.right, this.right).rightAssoc());
    }
    throw new Error('patterns exhausted');
  }
}

This works as expected. However, it has a problem. In the implementation of the Add#rightAssoc method, I'm throwing an error if expr is neither an instance of Num nor Add. Now, suppose I create a new subclass of Expr.

class Neg extends Expr<number> {
  public constructor(public expr: Expr<number>) {
    super();
  }

  public override eval() {
    return -this.expr.eval();
  }

  public override rightAssoc(): Neg {
    return new Neg(this.expr.rightAssoc());
  }
}

TypeScript doesn't complain that the series of instanceof checks in Add#rightAssoc is not exhaustive. Hence, we might accidentally forget to implement the case when expr is an instance of Neg. Is there any way we can simulate sealed classes in TypeScript so that we can check for exhaustiveness of instanceof checks?


Solution

  • I figured out a way to have exhaustive pattern matches and also prevent new classes from extending the sealed class. First, we need to define a new abstract method called match in the sealed class, Expr.

    abstract class Expr<A> {
      public abstract match<B>(which: {
        Num: (expr: Num) => B;
        Add: (expr: Add) => B;
      }): B;
    
      public abstract eval(): A;
    
      public abstract rightAssoc(): Expr<A>;
    }
    

    The match method allows you to pattern match on a known set of direct subclasses. Hence, the implementation of the match method for these direct subclasses is trivial. For example, here's the implementation of the Num#match method.

    class Num extends Expr<number> {
      public constructor(public num: number) {
        super();
      }
    
      public override match<B>(which: {
        Num: (num: Num) => B;
      }): B {
        return which.Num(this);
      }
    
      public override eval() {
        return this.num;
      }
    
      public override rightAssoc(): Num {
        return new Num(this.num);
      }
    }
    

    Similarly, we can implement the Add#match method. Furthermore, we can use this match method to implement the Add#rightAssoc method. TypeScript ensures that we always handle all the cases.

    class Add extends Expr<number> {
      public constructor(public left: Expr<number>, public right: Expr<number>) {
        super();
      }
    
      public override match<B>(which: {
        Add: (expr: Add) => B;
      }): B {
        return which.Add(this);
      }
    
      public override eval() {
        return this.left.eval() + this.right.eval();
      }
    
      public override rightAssoc(): Add {
        return this.left.rightAssoc().match({
          Num: (expr) => new Add(expr, this.right.rightAssoc()),
          Add: (expr) => new Add(
            expr.left,
            new Add(expr.right, this.right).rightAssoc()
          )
        });
      }
    }
    

    If we try to add a new subclass then we'll have to implement the match method. This would force us to update the definition of the match method in the sealed class. That would in turn force us to update every place where we used the match method to handle the new case.

    Making the match method abstract also makes it difficult to create a subclass of Expr. In order to implement the match method we'd need to call one of the handlers of the known direct subclasses. So for new subclasses which aren't known, the match method would be semantically incorrect.