This is a very common toy example we use in Haskell, though honestly this OOP version leaves a lot to be desired, comparatively
The issue is that it tries to shoehorn separation of data and functions on data into a paradigm that's fundamentally about fusing those two things together.
Here's the Haskell version. Note how much simpler it is because data and functions are separate:
data Expr
= Lit Int
| Add Expr Expr
eval :: Expr -> Int
eval expr = case expr of
Lit n -> n
Add a b -> eval a + eval b
print :: Expr -> String
print expr = case expr of
Lit n -> show n
Add a b -> "(" ++ print a ++ " + " ++ print b ++ ")"
Typescript can do something similar:
type Expr = {
kind: 'Lit',
value: number
} | {
kind: 'Add',
left: Expr,
right: Expr
}
function eval(expr: Expr): number {
switch(expr.kind){
case 'Lit': return expr.value;
case 'Add': return eval(expr.left) + eval(expr.right);
default:
const _impossible: never = expr;
return _impossible;
}
function print(expr: Expr): string {
switch(expr.kind){
case 'Lit': return `${expr.value}`;
case 'Add': return `(${print(expr.left)} + ${print(expr.right)})`;
default:
const _impossible: never = expr;
return _impossible;
}
Both the OOP approach and Typescript itself struggle with additions to the algebra composed of different types, however. For example, imagine extending it to handle booleans as well, supporting equality operations. It's difficult to make that well-typed using the techniques discussed thus far. In Haskell, we can handle that by making Expr
a GADT, or Generalized Algebraic Data Type. That article actually already provides the code for this, so you can look there if you're curious how it works.