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?