DEV Community

Yawar Amin
Yawar Amin

Posted on

Typing and code flow in TypeScript and ReasonML

WITH the advent of TypeScript as the most popular static typing tool for JavaScript, it's worth taking a look at how TypeScript and ReasonML approach the problems of static typing, inference, and code flow in some typical equivalent code. This is not an apples-to-apples comparison; however, in terms of functionality it is pretty close. My intended audience with this is primarily TypeScript and Reason folks. If you understand one of the languages (up to the basics of generics), you should be able to map between the implementations because of their similarity.

The example I'll use here is a simple mutable map data structure. In TypeScript, the most idiomatic way to build a data structure is using a class. For our map data structure, we can implement it like so:

// MapImpl.ts

class MapImpl<A, B> {
  private map: {[key: string]: B};

  constructor() { this.map = {}; }
  get(a: A): B | undefined { return this.map[JSON.stringify(a)]; }
  put(a: A, b: B): MapImpl<A, B> {
    this.map[JSON.stringify(a)] = b;
    return this;
  }
}

This map works by stashing values in a JavaScript object, keyed by the JSON serialized string of the given key. Thanks to TypeScript's good support for classes and private members, we get a pretty good data abstraction: trying to access map in MapImpl objects will be a compile-time error.

Within Reason

Let's look at the equivalent in Reason (using the BuckleScript compiler to target JavaScript):

/** MapImpl.rei */

type t('a, 'b);

let get: ('a, t('a, 'b)) => option('b);
let make: unit => t('a, 'b);
let put: ('a, 'b, t('a, 'b)) => t('a, 'b);

/* MapImpl.re */

type t('a, 'b) = Js.Dict.t('b);

let make = Js.Dict.empty;
let get(a, t) = a
  |> Js.Json.stringifyAny
  |> Js.Option.andThen((. key) => Js.Dict.get(t, key));
let put(a, b, t) = {
  a
  |> Js.Json.stringifyAny
  |> Js.Option.map((. key) => Js.Dict.set(t, key, b))
  |> ignore;

  t;
};

The first thing you'll notice is that this is two files, compared to the TypeScript version's single file. In OCaml (and thus Reason), to make a data type abstract you'll need to hide its implementation details from other code, and the most idiomatic way to do that is to give it an interface file (.rei) which just declares the type (type t('a, 'b)) but does not define it.

This is the essence of data abstraction in the OCaml world–the type t is abstract. We just see that it takes two type parameters 'a and 'b, and there are three functions which work with this type. As a side note, we can arrange these types and functions in a different order in the interface file–whichever order best serves our use case. In this case I've ordered the values alphabetically, to make them easier to look up.

The implementations

In this example, you'll notice that the Reason implementation is more verbose. This is because it's more explicit: operations show in their types that they return 'no result' (None), and also show that they use JavaScript objects as a 'dictionary' data structure.

The TypeScript in contrast preserves a lot of the 'JavaScript feel'. Not surprising given that it aims to be strictly a JavaScript superset. However it does lead to the code having a hidden layer of meaning. For example, it's implicit that certain operations could 'fail' and result in undefined, and there are automatic rules for how those undefined get handled.

Let's take a look at one such code flow: the MapImpl#put method. In it, first the JSON.stringify operation can fail (silently) and result in undefined. Then, this undefined can be used as the index key to this.map, which will then insert the value with the key undefined. This will cause a really subtle bug that you won't even notice until you try to insert two key-value pairs whose keys can't be stringified, and keep getting the wrong value back on lookup. The correct implementation is:

put(a: A, b: B): MapImpl<A, B> {
  const key = JSON.stringify(a);

  if (key) this.map[key] = b;
  return this;
}

At this point it's worth pointing out that the return type string in the TypeScript bindings for the JSON.stringify function is incorrect. It should really be string | undefined. It can be argued that this is fixable, but I believe that lots of TypeScript typings are similarly incomplete because they were written in a time when TypeScript understood any type T, anywhere, to really mean T | undefined. Later, the TypeScript compiler changed how it understood type declarations like T to mean really just (non-nullable) T. So any type T from before the change can implicitly mean T | undefined, meaning its code may silently fail.

Anyway, the point is that JSON.stringify and other typings are still fixable at the library level (one by one, e.g. issue for JSON.stringify). But the types of things like the indexing operation this.map[key] are not (realistically, anyway). If you change the get method to:

get(a: A): B | undefined {
  const result = this.map[JSON.stringify(a)];
  return result;
}

You'll notice that TypeScript infers the type of result as B. The problem is it's really B | undefined, because the key might not be in the object and the lookup might fail. This can't be fixed at the library level–it would have to be a breaking TypeScript compiler change (or at least a new compiler flag). This is the subject of ongoing discussion.

The benefit of verbosity

When we think about the semantics and the hidden code flow, it's clear that TypeScript and JavaScript do a lot for us behind the scenes. In a best effort to stay backwards-compatible with JavaScript, TypeScript infers the types of various operations and code paths.

What Reason is trying to do though is actually use a wholly-different semantics–that of OCaml–to make these operations more explicit and better represented in the type system itself, instead of hidden away by the special rules of the operations. The underlying data structures may be JavaScript objects, but we get access to them following OCaml's rules of type safety. It also doesn't hurt that the pipe-forward (|>) operator can be used to show the left-to-right, top-to-bottom flow of data inside function bodies.

If you're looking for a way to write JavaScript without dealing with special rules and hidden meanings in your code, ReasonML may be worth a try.

Discussion (6)

Collapse
olvnikon profile image
Vladimir

I've been working a lot with typescript recently and I would say it is pretty bad comparing to its analogues. It forces to focus on writing complex types instead of inferring. It doesn't feel javascript. It is not a sound type system. It doesn't guarantee type safety. Flow is pretty good in inferring types. Reason is amazing in this and gives you 100% static type coverage. While I was working with Flow or Reason I was focused on coding rather than on calculating types. For me typescript is a huge mistake. Why to use such a system that is not really connected to the language still requiring to write a lot types and utilities while it doesn't provide type safety?

Collapse
yawaramin profile image
Yawar Amin Author

I also feel that TypeScript has a lot of footguns that can lead to bad coding practices, e.g. I recently came across typescriptlang.org/docs/handbook/u... which can construct a new type by picking out some properties of an existing type. I believe this can lead to strong coupling and dependency cycles between different layers of a program. You're right about soundness and type safety, I really feel those are the number one priority of a type system to get correct.

Collapse
actionshrimp profile image
Dave Aitken

Great post!

Must admit I was maybe expecting slightly more detail in how Reason / OCaml's rules help avoids these shortcomings - felt like the end came abruptly just as I was getting into it.

Great writeup overall though - very informative about the shortcomings of TypeScript for someone coming from the Reason side having not played around with TypeScript enough. People have said that TypeScript is unsound and now I understand a bit more as to why. Thanks!

Collapse
yawaramin profile image
Yawar Amin Author

Thanks! :-) I think you're right, I could probably have written a bit more about specifically Reason/OCaml type system that prevents lots of implicit errors from happening like they would in JS/TS. I'll try to come up with something for a future post.

Collapse
deciduously profile image
Ben Lovy • Edited on

Really great comparison! This gets to the heart of why I've been preferring Reason lately.

As an aside, you can trick Dev.to into syntax highlighting Reason code by marking it "ocaml".

Collapse
yawaramin profile image
Yawar Amin Author

Thank you! And also for the syntax highlighting tip–that looks way better.