DEV Community

Magne
Magne

Posted on • Updated 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.

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

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

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

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

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 negatives, 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.

Discussion (0)