A sealed class in Kotlin is a unique type of abstract class where all its direct subclasses are known during compile time. These subclasses must be defined within the same module as the sealed class itself, preventing any external modules from extending it. This feature allows the Kotlin compiler to conduct exhaustiveness checks on the sealed class, similar to how TypeScript handles unions. The question arises whether a similar implementation can be achieved in TypeScript.
Let's analyze an abstract class called Expr
, along with 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();
}
}
An instance of the Expr
class is demonstrated below.
// (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)
);
The goal is to convert 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))))
);
To achieve this transformation, we introduce a new abstract method called rightAssoc
to the Expr
class.
abstract class Expr<A> {
public abstract eval(): A;
public abstract rightAssoc(): Expr<A>;
}
Both the Num
and Add
subclasses implement this method accordingly.
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');
}
}
While this approach works correctly, there exists a potential issue. Within the Add#rightAssoc
method, an error is thrown if the expr
object does not belong to either the Num
or Add
classes. In case a new subclass of Expr
is introduced, such as Neg
, TypeScript might not warn about the incomplete instanceof
checks. Is there a way to simulate sealed classes in TypeScript to ensure exhaustive checking for instanceof
statements?