DEV Community

Magne
Magne

Posted on • Edited on

TypeScript vs. ReScript vs. F# - a simple comparison of syntax

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)
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode

ReScript - try/edit code online

let square = tallRect->add(wideRect)->add(wideRect)
Enter fullscreen mode Exit fullscreen mode
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/
Enter fullscreen mode Exit fullscreen mode

F# and Fable - try/edit code online

let square = tallRect + wideRect + wideRect
Enter fullscreen mode Exit fullscreen mode
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 }
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
redbar0n profile image
Magne • Edited

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:

  1. All methods have to be inside the class (or inside a common abstract class for ADTs)

  2. 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).

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

Collapse
 
saulpalv profile image
Saul Alonso Palazuelos

F# feels nicer on .NET runtime than Fable

Collapse
 
redbar0n profile image
Magne

Interesting, could you elaborate a bit?