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?
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.