ReScript Teaser

ReScript – the language after TypeScript?

No Comments

In the confusing jungle of transpiler languages for JavaScript, there are some gems. TypeScript is mainstream, ReScript is starting to establish itself, and Elm is still an insider tip. This article takes a detailed look at ReScript – but also sheds light on the limitations of the young language. In what projects does its use make sense? What projects should rather use TypeScript on the one hand or Elm on the other?

In web environments, nothing works without JavaScript. Not surprisingly, JavaScript is a popular target language for transpilers – compilers that do not convert to bytecode but into the source code of another language. Teams like to use transpiler languages such as TypeScript, ReScript, or Elm for their advantages like static typing, modern language features, or support for functional programming. Static typing and functional programming are of particular interest here.

Static typing

The topic of static typing is highly controversial and often leads to religious wars. A strong type system helps detect and avoid errors. But whether type checks should happen at runtime or at compile time is a matter of opinion. More objectively, there are arguments for both sides. Clear advantages of static type systems are

  • early error detection (type safety)
  • auto-completion
  • documentation
  • optimizations

Type safety

The costs for the removal of a defect depend on the moment of its discovery. If an error is discovered that has just occurred, it is usually quickly found and corrected. An error that a customer discovers months later in production often takes considerably more time to fix. Almost every experienced developer has an anecdote about how he or she spent three days looking for a bug.

Static typing helps detect certain error classes at an early stage and thus avoid efforts like debugging. Test-driven development (TDD) has the same effect. However, type safety and TDD address different error classes. A combination of both is desirable. The claim that static typing alone leads to less error-prone code is controversial – scientific studies have not been able to prove it.

Auto-completion & Documentation

In many web projects it is not uncommon to constantly use new APIs from libraries or even other project teams. In this respect, a good autocomplete in the IDE can save a lot of time. Even without auto-completion, type specifications serve as documentation and help to understand functions faster.

Optimization

A compiler/transpiler that knows the type of a value can sometimes perform optimizations in the generated code that would otherwise not be possible.

Costs of static typing

On the other hand, it is necessary to consider the costs of static typing. These include:

  • Code noise
  • additional effort due to type conversion
  • higher compiler runtimes
  • increased complexity

Code noise

Code noise means that the readability of code can deteriorate significantly when many type annotations obscure the view. JavaScript blogger Eric Elliot calls this TypeScript tax and questions the return of investment of static type systems.

Effort, complexity & compilation time

Additional effort may be required if it is necessary to convert values into other types, e.g. to pass them to an external function. Long compilation times can cause developers to lose focus while waiting for the result of a change – a frustrating experience for experimental tasks. Finally, a static type system comes with its own set of concepts that need to be mastered – resulting in an increase in complexity.

Exploiting the type system

Since static type systems have advantages and disadvantages, there are two strategies:

Option 1: The team forgoes a type system and thus the benefits, but also the costs – the language of choice is classic JavaScript or TypeScript without type annotations. This option is often ridiculed, but it can be a legitimate choice. To ensure the quality of the software, high test coverage is even more important here.

Option 2: The team tries to exploit the type system as much as possible. That is, it is important to maximize the advantages and minimize the disadvantages. Some features of the type system can help: Type inference and structural typing can minimize code noise and refactoring efforts. Discriminated unions help write code that is as error-free as possible and detect problems at compile time – more on this later.

Functional programming

An increasing number of JavaScript developers are discovering functional programming for themselves – libraries such as underscore, lodash or RamdaJS have long been favorites of many teams. The functional-inspired React is considered the most popular UI-framework and even the classic object-oriented Angular relies on a variant of functional programming in the form of RxJS. The strengths of functional programming include:

  • Immutable data structures
  • A stateless programming model
  • Simple composition of functions
  • Reduction of complexity

One of the big problems of imperative programming is the so-called Mutable State – i.e. a state that can be changed. The fact that the state changes means that the code at runtime can differ significantly from the code that the IDE shows statically. As a result, a debugger is often required to understand what the code is doing in the first place. Functional programming minimizes the problem by avoiding mutable state. To ensure that this is possible in performant way, the language must be optimized for this, or the team uses an appropriate library, such as Immer or seamless-immutable.

However, libraries can extend JavaScript only to a limited extent. Some concepts require direct language support.

Transpiler languages

Transpiler languages like TypeScript, ReScript, and Elm help by providing a static type system and support for functional concepts.

TypeScript

TypeScript hardly needs an introduction, as it is the most popular of the transpiler languages. The State of JS 2019 study also reported the highest developer satisfaction:

State of JS 2017 - Language Flavors: Developer Satisfaction

State of JS 2019 – Language Flavors: Developer Satisfaction

A closer look reveals that TypeScript does not resemble rigid type systems of well-known OOP languages; rather it has features that are more reminiscent of functional languages. These include, among others:

Structural typing

TypeScript relies on so-called structural typing, i. e. not the name of a type is relevant (nominal typing), but the structure. Here is an example:

type Person = { firstName: string; lastName: string };
type User = { firstName: string; lastName: string };
type Dog = { firstName: string };

let person1: Person = { firstName: "Alice", lastName: "Foo" };
let user1: User = { firstName: "Bob", lastName: "Bar" };
let dog1: Dog = { firstName: "Wuffy" };

let greet1 = (person: Person) =>`Hello ${person.firstName}`;

greet1(person1); //OK
greet1(user1);   //OK
greet1(dog1);    // => Error (as expected)

This brings some advantages: converting a value to match the type is not necessary. As long as the structure is the same, TypeScript accepts the value. This is also a prerequisite for so-called structural subtyping – a concept similar to duck typing that is only available in dynamic languages:

let greet2 = (person: { firstName: string }) =>`Hello ${person.firstName}`;

greet2(person1); //OK
greet2(dog1);    //OK, the obj has prop "firstName"
greet2(27);      // => Error (as expected)

Function greet2 accepts any object as long as the subtype “an object with firstName of type string” is fulfilled. Whether or not the object has other properties, such as lastName, is irrelevant for the call of greet2.

Discriminated union types

To maximize the error detection of a type system, it is important to design types correctly: valid values must be representable in the type system, invalid ones must be rejected. Domain-driven design calls this enforcing invariants (see Domain-Driven Design: Tackling Complexity in the Heart of Software). Of course, it is easy to code checks that enforce invariants at runtime. However, a good type system checks for invariants at compile time. Here an example with so-called discriminated union types:

type Color = "green" | "red" | "blue";

type Circle = {
  kind: "Circle";
  radius: number;
};

type Rectangle = {
  kind: "Rectangle";
  height: number;
  width: number;
};

type Shape = Circle | Rectangle;

let circle1: Shape = { kind: "Circle", radius: 10 };       //OK
let rectangle1: Shape = { kind: "Rectangle", radius: 10 }; //Error correctly detected
let rectangle2 = { kind: "Rectangle", radius: 10 };        //OK, but should be an Error

In the case of color, the union type still looks like a simple enum. However, shape should make clear that different variants of shapes can also bring different data. A shape here is either a circle or a rectangle. Circles have a radius, rectangles height and width. A rectangle must not have a radius.

If a value (here: the variable rectangle1) is marked as a shape, TypeScript can detect the error – in our case: rectangle with radius. Sidenote: kind is not a keyword of the language, any identifier is possible.

If the type annotation is missing, as is the case with rectangle2, the type system of TypeScript unfortunately fails to detect the error. TypeScript is considered unsound. ReScript goes a step further here and can detect the error with rectangle2 using type inference – more about this later.

AnyScript

The TypeScript type system is particularly helpful for teams that have the discipline to type consistently. However, TypeScript also provides the ability to gradually type less. This is a useful feature for teams that plan to migrate existing JavaScript code to TypeScript piece by piece. The downside, however, is that it can cause the team to neglect typing. The temptation is always near to assign an any instead of a difficult type annotation or even to switch off the typing by means of an @ts-ignore comment.

What saves time at first usually takes its toll later on, namely when the compiler lets errors pass that a consequent typing would find. The remaining code brings along some disadvantages of static typing without being able to profit from the advantages (“worst of both worlds”) – an unfortunately widespread anti-pattern in practice.

This has already earned TypeScript the mocking name of “AnyScript“. In any case, it is recommended to run the TypeScript transpiler with the –strict flag (all strict rules) or at least –noImplicitAny.
This way TypeScript does not interpret a missing type annotation as any anymore, but indicates the absence of the annotation. Unfortunately, no compiler flag can replace team discipline.

JavaScript compatibility

The excellent compatibility with JavaScript is the outstanding feature of TypeScript. In fact, Microsoft likes to introduce TypeScript as “It’s just JavaScript, …”.
Even complex type situations can be represented in TypeScript. For example, variadic functions, i.e. functions with different numbers of arguments or functions with varying return type. Unlike other transpiler languages, TypeScript aims to be a complete superset of JavaScript.

Functional Programming with TypeScript

Beyond the type system, TypeScript itself doesn’t provide much which makes functional programming easier. However, there is nothing wrong with using additional libraries: TS-FP and RamdaJS are popular candidates.

ReScript – a rediscovery

ReScript (formerly ReasonML) is the brainchild of React framework creator Jordan Walke. Although the language has only existed since 2016, an entire team is now working on its further development. Besides Facebook and financial giant Bloomberg, volunteer enthusiasts from all over the world are involved. It is therefore hardly surprising that the project has over 5,000 stars and almost 300 participants on GitHub and scores very well in surveys on developer satisfaction, as State Of JS shows. In the State of JS 2018 study, ReScript (at that time still called ReasonML) even won the prediction award – the award for emerging technologies.

Surprisingly, however, ReScript is not a completely new language. The underlying OCaml has been around since 1996, and Walke noticed that OCaml was a good fit for React, but the unfamiliar syntax put off many front-end developers. So he came up with the idea: How about giving the OCaml language a syntax that allows JavaScript developers to quickly find their way around? Thus, ReScript allows the concepts of a mature functional programming language with the popular Hindley-Milner type system to be transferred to the frontend in a very short time. This works surprisingly well: ReScript is relatively easy to learn for JavaScript and TypeScript developers. The language comes with an excellent type system, high performance and concepts from functional programming. For such a young language, there is also already relatively sophisticated tooling available.

Sidenote: OCaml and ReScript share the same roots as F# – another member of the ML-family which is great alternative in Microsofts .Net-ecosystem.

Seamless typing

The type system is considered “sound”, i.e. ReScript code is always 100 percent typed – a backdoor like TypeScripts any or @ts-ignore was deliberately omitted. This saves a tool for type coverage analysis and prevents errors caused by insufficient type annotations.

However, thanks to type inference, specific annotation is only necessary in a few cases: the transpiler determines the type of each value. If this is not possible, the compilation aborts with an error. ReScript thus ensures discipline in typing while requiring fewer type annotations than TypeScript. Here is an example of type errors that TypeScript cannot detect:

let greet2 = (person: { firstName: string }) => `Hello ${person.firstName}`;
let greet3 = (person: any) =>`Hello ${person.firstName}`;
let greet4 = (person) =>`Hello ${person.firstName}`; // Error with --noImplictAny

greet2(27);    // => Error (as expected)
greet3(27);    //OK, but should be an Error

greet4(person) //OK (as expected)
greet4(27);    //OK, but should be an Error

Take a look at the ReScript version:

type person = {lastName: string, firstName: string}

let person1 = {firstName: "Alice", lastName: "Foo"}

let greet1 = (p: person) => "Hallo " ++ p.firstName

let greet4 = p => "Hallo " ++ p.firstName

greet4(person1) //OK (as expected)
greet4(27);     //Error (as expected)

ReScript detects the type error in the call of greet4, as the Visual Studio Code Plugin demonstrates:

ReScript detects errors via typeinference
In general, ReScript recognizes many type problems without requiring additional annotations (see also Inference Engines).

An IDE with a suitable plug-in can already show current types during development without the need for an explicit annotation. Visual Studio Code shows a tool tip if the mouse pointer hovers over a value or function like greet4:

Mouse over greet4 shows tool tip with type

The type inference recognizes person1 as a person by its structure:

Mouse over person shows tool tip with type by inference

Both functions greet1 and greet4 have the type “person => string“. This means the argument is a person and the return value is of type string. greet1 has an explicit type annotation for person. However, greet4 demonstrates that this is not necessary at all and that the type inference recognizes it even without annotation. Thus, ReScript can easily detect the error in the last line: “This has type: int. Somewhere wanted: person”.

Variant type

The counterpart of discriminated unions in ReScript is called variant types and is one of the core features of the powerful Hindley-Milner-based type system. Here is the above form-example in ReScript:

type shape =
| Circle({radius: int})
| Rectangle({height: int, width: int})

let circle1 = Circle({radius: 10}) //OK
let rectangle1 = Rectangle({height: 5, width: 4}) //OK

let rectangle2: shape = Rectangle({radius: 10}) //Error correctly detected
let rectangle3 = Rectangle({radius: 10})        //Error correctly detected

In the example, the improved syntax is easy to see. A shape is either a circle or a rectangle. Both variants have different data: A circle has a radius, a rectangle height and width. ReScript can easily detect the error even without type annotation.

The billion-dollar mistake

Tony Hoare, to whom we owe many useful concepts in computer science, has apologized after 40 years for the rather less useful introduction of null values. He estimates that the damage that programming errors caused by null values have brought to the industry is in the billions. The solution to the problem is optionals. Like in other ML languages, there are no null values in ReScript. It uses optionals instead, that allow the compiler to detect errors before execution. If a value can take the state None, the compiler requests an explicit handling.

Functional Programming

In his talk React to the Future, Jordan Walke highlights that ReScript is now extending the ideas and concepts that JavaScript developers like in React with respect to the entire language. These include minimizing state and side effects and making code easy to compose. Concepts that are mostly found in functional programming languages.

JavaScript also has its roots in functional programming. Since the version ECMAScript 2015 and the associated improvements, the FP approach has once again become much more popular in the JavaScript community. ReScript also goes a few steps further here and offers many more functional concepts. At the same time, ReScript fits so seamlessly with JavaScript that it is sometimes jokingly referred to as “ECMAScript 2030”. For example, ReScript provides the pipe operators -> and |>, a language feature that is currently available as a proposal for JavaScript.

Functional composition is one of the outstanding features of functional programming. ECMAScript 5 has already introduced higher-order functions like map and filter that take other functions as parameters. ECMAScript 2015 added the Arrow functions as a major syntax improvement.

Higher-order functions on the Array object are excellent for building functional pipelines. As an example, a keyword directory is to be created from a list of words in which the words are assigned to their respective initial letter. However, only short words (less than five letters) are to be considered and the output must be completely in upper case letters.

The first part, that is, filtering the short words and turning them into capital letters, can be achieved in JavaScript as follows:

const shortWordsUpperCase = words =>
  words.filter(w => w.length < 5).map(s => s.toUpperCase());

In ReScript, no methods are needed on the array object. Instead, the pipeline operator -> can pack the result of one function call into the next:

let shortWordsUpperCase = words =>
  words->Array.keep(w => String.length(w) < 5)->Array.map(Js.String.toUpperCase)

The advantage is that this works not only for array methods, but with arbitrary functions. Accordingly, the rest of the task can also be solved using pipelines:

let pair = (letter, words) => (words, letter)

let partitionByFirstLetter = words => {
  let letters = ["A", "B", "C", "F"]
  letters->Array.map(l => words->Array.keep(Js.String.startsWith(l))->pair(l))
}

let shortWordsUpperCaseByLetter = ws => ws->shortWordsUpperCase->partitionByFirstLetter

Js.log(words->shortWordsUpperCaseByLetter)

Thus, functional programming in ReScript is not a radical break from modern JavaScript – it seems more like a consistent evolution. This is also reflected in other functional features such as advanced destructuring & pattern matching, auto-currying or immutability by default.

JavaScript compatibility

Interaction with existing JavaScript code is also possible. External JavaScript code can be typed afterwards with the help of a series of annotations. However, this is not always easy for libraries with highly dynamic behavior. The following example embeds the React component for the button from the Ant-Design CSS framework.

@bs.module("antd/lib/button") @react.component
external make: (
~onClick: ReactEvent.Mouse.t => unit=?,
~disabled: bool=?,
~id: string=?,
~className: string=?,
~children: React.element=?,
~block: bool=?,
~size: [#default | #middle | #small]=?,
~_type: [#primary | #ghost | #dashed | #danger | #link | #text]=?,
) => React.element = "default"

This is easier with TypeScript, not least because the DefinitelyTyped repository provides TypeScript declarations for virtually all relevant JavaScript libraries. ReScript catches up a bit here.
The number of bindings on redex.github.io is growing steadily, and creating your own bindings, while time-consuming, is getting better with newer versions of ReScript.

Tooling

The tooling is surprisingly good for such a young language. The platform comes with a generator for creating your own projects, similar to Create-React-App. Using the theme parameter, developers can determine whether they want to create a pure ReScript or a ReasonReact project. This is especially helpful for beginners who do not want to deal with compiler settings and appropriate webpack configuration. Provided that NodeJS is already installed, an executable project is ready after only a few commands:

npx -p bs-platform bsb -init myproject -theme react-hooks

cd myproject

npm i

The platform also comes with a formatting tool similar to Prettier for JavaScript, so that superfluous syntax discussions do not even arise. A series of plug-ins for well-known IDEs such as Visual Studio Code, Idea, Sublime, Atom or VIM complete the successful range of tools.

Elm

If one wishes still more functional programming for web development, Elm delivers exactly that. The language is more Haskell-oriented and brings along many other concepts of functional programming. Developers appreciate the transpiler language mainly because of its development experience and lack of historical legacy. In addition, the standard libraries boast well-thought-out concepts and amazing consistency. The fact that Elm combines its language concepts with a framework for web development is another advantage. The so-called Elm architecture is considered groundbreaking: This kind of state management has also gained acceptance outside the community and served, for example, as a model for the JavaScript library Redux.

The default example from the Elm tutorial is a counter that can be increased or decreased by pressing a button:

main =
  Browser.sandbox { init = 0, update = update, view = view }

type Msg = Increment | Decrement

update msg model =
  case msg of
    Increment ->
      model + 1

    Decrement ->
      model - 1

view model =
  div []
    [ button [ onClick Decrement ] [ text "-" ]
    , div [] [ text (String.fromInt model) ]
    , button [ onClick Increment ] [ text "+" ]
    ]

However, interaction with existing JavaScript libraries and frameworks is rather difficult for Elm apps. This has led, for example, the “backend as a service” provider Darklang to turn away from Elm and convert its entire platform into ReScript.

Conclusion

As so often, the decision what transpiler language to choose depends on the specific project. Those who primarily value maximum compatibility with JavaScript or want to enrich existing JavaScript code with a type system should take a closer look at TypeScript. Possibly self-written libraries are in use for which there are no existing type definitions and which at the same time provide a wide range of hard-to-type APIs. In this case, a subsequent typing with ReScript would be too time-consuming.

Developers whose projects do not have any dependencies on existing JavaScript code and do not need any libraries from the JavaScript ecosystem can use Elm without hesitation. Those who are not afraid of the somewhat steeper learning curve will find a well-thought-out functional language, an excellent type system and an extensive standard library.

ReScript sits somewhere between these two extremes. JavaScript libraries that do not come with too many special cases are easy to retype. With a little extra effort, more complex cases can be accommodated. Moreover, for a functional language, ReScript is exceedingly beginner-friendly. Thanks to the JavaScript-like syntax, ReScript is easy to learn, especially for those who have JavaScript experience. The main advantage is a type system that has a very good cost-benefit ratio. The consistent focus on functional programming with the possibility to fall back to classical concepts if necessary allows for writing comprehensible code. The extremely fast compiler and the well-developed tooling lift the development experience up to a high level. At the same time, it is still possible to fall back to existing JavaScript libraries and frameworks.

Note: Previous versions of this article were published in German in t3n magazine No. 61 and Softwerker 16.
ReScript Logo & Teaser image by Bettina Steinbrecher, with friendly permission
Thanks to Diana Kupfer, Florian Wiech & John Fletcher for proof reading

Update: add Sidenote about F#, thanks to my colleague Goetz Markgraf for the suggestion

Marco Emrich

Marco is Senior Consultant at codecentric. As a passionate advocate of software crafting and code quality, Marco has many years of experience in architecture and development. Marco regularly lectures at well-known conferences and is the author of several programming books. When he is not organizing the Softwerkskammer Nuremberg (developer meetup), he is probably explaining to his son how to program robotic turtles.

Comment

Your email address will not be published. Required fields are marked *