How could you get the nice obj.add(obj2).add(obj2)
chaining from OOP, without the downside of unrestricted mutability?
Let's compare how 3 languages that compile to JavaScript - TypeScript, ReScript, and F# with the Fable compiler - are able to chain immutable data (aka. create an Immutable Fluent Interface aka. a kind of Fluent API).
We'll use the simplest appropriate data structure we can think of: a Rectangle with 2 properties (height and width). Let's make some Rectangles, and add their properties together, so the resulting Rectangle is a square.
(Huge thanks to @texastoland for coming up with most of these examples and aiding in refining them.)
TypeScript (plain) - try/edit code online
const square = tallRect.add(wideRect).add(wideRect)
class Rectangle {
readonly height: number
readonly width: number
constructor(height: number, width: number) {
this.height = height
this.width = width
}
add(rec: Rectangle) {
return new Rectangle(this.height+rec.height, this.width+rec.width)
}
}
const tallRect = new Rectangle(1, 3)
const wideRect = new Rectangle(3, 2)
// Usage, chaining:
const square = tallRect.add(wideRect).add(wideRect)
console.log(square) // Rectangle: { "height": 7, "width": 7 }
square.height = 20 // TS correctly errors on this. However, the JS would still execute (when run in e.g. https://www.typescriptlang.org/play )
console.log(square)
We can make the TypeScript version slightly more terse. If you come from FP you might dislike new
keyword, so instead of a normal object constructor we use a function make
instead (a convention from ReScript and OCaml). Rect.make
better signifies that it is an immutable object, since with the new
keyword the next programmer could think she got a normal object she'd be able to mutate as any other.
TypeScript (terser) - try/edit code online
const square = tallRect.add(wideRect).add(wideRect)
class Rect {
constructor(readonly width: number, readonly height: number) {}
static readonly make = (w: number, h: number) => new this(w, h)
readonly add = ({ width, height }: Rect) =>
Rect.make(this.width + width, this.height + height)
}
const tallRect = Rect.make(1, 3)
const wideRect = Rect.make(3, 2)
// Usage, chaining:
const square = tallRect.add(wideRect).add(wideRect)
console.log(square) // Rectangle: { "height": 7, "width": 7 }
square.height = 20 // will error, since it's immutable, ignore by prepending with // @ts-expect-error
console.log(square)
ReScript - try/edit code online
let square = tallRect->add(wideRect)->add(wideRect)
type rect = {
width: float,
height: float,
}
let add = (r1, r2) => {
width: r1.width +. r2.width, // the +. is for adding floats
height: r1.height +. r2.height,
}
let tallRect = {width: 1., height: 3.}
let wideRect = {width: 3., height: 2.}
// Usage, chaining:
let square = tallRect->add(wideRect)->add(wideRect)
Js.log(square) // { "height": 7, "width": 7 } // The ReScript playground will output JS that can be run in JSFiddle: https://jsfiddle.net/8a3evo4u/
square.height = 20 // will error, since it's immutable
Js.log(square) // { "height": 7, "width": 7 } // The ReScript playground will output JS that can be run in JSFiddle: https://jsfiddle.net/8a3evo4u/
F# and Fable - try/edit code online
let square = tallRect + wideRect + wideRect
type Rect =
{
Width: float
Height: float
}
// here we use a type extension, NB: https://fsharpforfunandprofit.com/posts/type-extensions/
member r1.add(r2) =
{
Width = r1.Width + r2.Width;
Height = r1.Height + r2.Height;
}
static member (+)(r1: Rect, r2: Rect) = r1.add(r2) // enables syntax sugar
let tallRect = { Width = 1; Height = 3 }
let wideRect = { Width = 3; Height = 2 }
// Usage, chaining:
let square = tallRect.add(wideRect).add(wideRect)
let square2 = tallRect + wideRect + wideRect // uses syntax sugar
printfn "%O" square // { Width = 7 Height = 7 }
square.Height <- 20 // will error, since it's immutable
square2.Height <- 20 // will error, since it's immutable
printfn "%O" square // { Width = 7 Height = 7 }
Notice how in the ReScript and F# versions you don't have to make properties immutable with something like readonly
. You also don't have to annotate types apart from the declaration, whereas in each TypeScript version you have to help the type system by annotating types (at least) at every function boundary, to say if it takes in a number
or a Rect
(such annotations can quickly add up and become boilerplate and noise).
That's because both ReScript and F# were derived from OCaml, so they also have the powerful Hindley-Milner (H-M) type inference. H-M type inference is also sound, which means you can rely on it (it prevents all type errors it claims to prevent, and doesn't give false positives, so you can trust that all type checked programs will be correct). That's something you can't take for granted in TypeScript, even with the extra annotations.
Top comments (3)
On the limitations of Fluent APIs:
"
This fluent type of API is "a dream", but also has a few drawbacks that have led the biggest part of the FP community away from it.
The limitations are staggering:
All methods have to be inside the class (or inside a common abstract class for ADTs)
Once a class is defined adding methods to it from the outside requires module augmentation and unsafe mutation of object prototypes. In other words: they aren't extensible (i.e. adding a method to a class coming from a library).
Worst of all, none of it can be optimized from a tree shaking perspective, so you'll end up with a huge bundle size that becomes prohibitive at a certain point
The current "solution" that the FP community has adopted is the use of pipeable APIs.
"
dev.to/effect-ts/the-case-for-ts-18b3
F# feels nicer on .NET runtime than Fable
Interesting, could you elaborate a bit?