Typing and code flow in TypeScript and ReasonML

Yawar Amin - Mar 9 '19 - - Dev Community

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

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

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

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

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.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .