Changing Mindset from Object-Oriented JavaScript to Functional ReScript
This article might be easily titled:
- From object-oriented C++ to functional Haskell
- From object-oriented C# to functional F#
- From object-oriented Python to functional OCaml
- etc
The main idea is to show how OOP (Object Oriented Programming) concepts can be projected to FP (Functional Programming) while accomplishing the same tasks. It’s always good to base on specifics for the sake of an example. So, I’ve chosen the JavaScript vs. ReScript combo for the illustration because these are the languages I use the most currently.
I’m expecting you’re a developer familiar with JS who uses objects, their methods, and properties regularly. Not sure, you are creating your own classes to get things done, but definitely use objects from third-party libraries, feel what myObj.foo.func()
means, seen that “Boom! undefined is not a function” for many times, and maybe even never thought if you might do things another way.
Destructuring object-oriented programming
OOP is a programming paradigm. It is a synthetic concept that offers a way to structure your program. You know, OOP is ubiquitous: most popular programming languages allow or enforce to structure programs and libraries this way.
However, objects are not the only way of programming and are definitely not a silver bullet solution to all problems. Objects were proven to have downsides: composability issues, implicit state dependencies, monolithness, and others. One possible alternative is the functional programming paradigm.
And what does that “functional” mean in practice? I’m going to break down OOP into parts, see what problems they are intended to solve and try to find a functional programming way to do the similar. The parts I’m referring to are:
- encapsulation
- abstraction
- inheritance
- polymorphism
Part 1: Encapsulation
Encapsulation, arguably, is the most recognized part of OOP. It is that dot (.
) allowing you to drill down the objects to obtain a value you want or a method you are going to call.
Formally speaking, encapsulation is an ability to:
- group related data and functions within a single thing (known as object);
- using a reference to the object, access to the data (known as fields);
- using a reference to the object, call its functions to operate over the data (known as methods).
Many languages extend the concept with things like “properties” (fields that are methods actually, aka getters/setters), “events” (fields that are arrays of callback function pointers actually), and other features. Still, it does not change the big picture.
To illustrate encapsulation, let’s make some burgers with JavaScript.
class Burger {
constructor(title) {
// Add a field `layers` to burger objects
// Let it be an array of layer objects
// Arrays are by themselves objects too, with methods `push`, `splice`, etc;
// so, we have a nested hierarchy of objects here
this.layers = [];
this.layers.push(new BreadRoll());
this.layers.push(new BeefPatty());
this.layers.push(new BreadRoll());
// Another field to hold a menu title
this.title = title;
}
// Provide a method to further build a burger
addLayer(layer) {
// access the array method and its `length` property through `this` reference
// to insert a new layer right before the last bread roll
this.layers.splice(this.layers.length - 1, 0, layer);
}
}
// Construct a couple of different burgers using the class we’ve just defined
let cheeseburger = new Burger("Cheeseburger");
cheeseburger.addLayer(new Cheese());
let kingburger = new Burger("Special King Burger");
kingburger.addLayer(new SecretSauce());
kingburger.addLayer(new Cheese());
kingburger.addLayer(new Onion());
kingburger.addLayer(new Tomato());
We’ve built (an oversimplified) system to describe burgers as objects. Now, we can pass Burger
s around an app to compute prices, show menu, take orders, manage a cooking queue, and so on.
OK, and if we make an app using the functional programming paradigm, how will the code look? Most FP languages, including ReScript, lack the concept of classes along with their props and methods at all. Functional languages strictly separate data from behavior and algorithms. Data and functions are the bread and butter of functional programming, with a clear point stating that bread ≠ butter. Given that, let’s start with a definition of the data we operate upon:
// === Burger.res ===
type t = {
title: string,
layers: array<Layer.t>,
}
Here we define a new type that groups all the data related to burgers. The type is a record with two fields to model our burgers. It’s that simple. No methods, no indirection, no funky syntax: just what a JS programmer would call a “plain old JavaScript object.”
The t
name is a ReScript convention for a type describing the primary data type of the current module. It’s handy because you can then fluently refer to such types from other modules like this: Burger.t
, Layer.t
, Order.t
, etc.
We’ve got data; let’s move on to the behavior, that is, to the functions. First, we’re going to add a constructor for our type. A user of Burger.t
might easily create a new instance directly by specifying all the fields one by one:
let myBurger = {
title: "My personal burger",
layers: [],
}
…but following the same logic as in the JavaScript example, let’s pre-populate layers with a very basic ingredient stack:
// === Burger.res ===
type t = {
title: string,
layers: array<Layer.t>,
}
let make = title => {
title: title,
layers: [
Layer.BreadRoll,
Layer.BeefPatty,
Layer.BreadRoll,
]
}
Again, nothing fancy here. Constructors are just regular functions conventionally named make
or makeBlahBlahBlah
. Our constructor takes a string as a parameter and returns a new Burger.t
.
The final bit is our addLayer
function:
// === Burger.res ===
type t = {
title: string,
layers: array<Layer.t>,
}
let make = (title) => {
title: title,
layers: [
Layer.BreadRoll,
Layer.BeefPatty,
Layer.BreadRoll,
]
}
let addLayer = (burger, layer) =>
switch burger.layers->ArrayX.last {
| Some(last) =>
// put the layer before the last one (which is a bread roll)
let first =
burger.layers
->Array.slice(~offset=0, ~len=burger.layers->Array.length - 1)
// list new layers
{
...burger,
layers: Array.concatMany([first, [layer], [last]]),
}
| None =>
// hmmm... someone messed up with layers, let it be a burger
// of one ingredient
{ ...burger, layers: [layer] }
}
Now a developer can use our system:
let kingburger = Burger.make("Special King Burger")
->Burger.addLayer(SecretSauce)
->Burger.addLayer(Cheese)
->Burger.addLayer(Onion)
->Burger.addLayer(Tomato)
These two previous snippets are pretty simple but carry so many essential details of FP and ReScript in particular. Let’s look at them one by one.
Pipes
The ->
operator in ReScript is known as a fast pipe. It’s a syntax sugar over regular function call that puts the value on the left-hand side as the first argument of the function on the right-hand side. The following are equivalent:
myBurger->Burger.addLayer(Cheese)
Burger.addLayer(myBurger, Cheese)
Thanks to the fast pipe, working with data almost feels like working with objects in OOP using its dot-notation. But in contrast to OOP, accessing “object” (data), “methods” (compatible functions) is not a unique language mechanic; it’s an alternative syntax of the good old plain function call. The “object” (the one with type t
) is conventionally passed as the first argument explicitly. Beautiful, eh?
No methods, no monkey-patching
In the kingburger
construction pipeline above, you might be caught on the repetition of Burger.
, Burger.
, Burger.
. These qualifiers are a direct consequence of the fact that ->
is just a function call; it’s not something that belongs to the “object.” We have to tell ReScript the module name where the functions are defined, thus the module prefix on every step.
It might look annoying, but in practice, it’s beneficial. First, when you read code, you can easily follow the most complex processing pipelines without guessing what type a method returns and where to find a class with such a method: the code is much more self-documenting. Second, such (ugly) things as object monkey-patching or polyfills are just irrelevant in ReScript: if you miss a “method” on an “object” you don’t control, go ahead and write the desired new function in a module you do control and use it.
Note, in the example above I used ArrayX.last
to get the last element of an array. The Array
module of the standard ReScript library does not include such a function, but I find it handy in this project. So I’m free to create a module (say, ArrayX
) and add whatever array utilities I find useful (e.g., ArrayX.last
). There are no agonies of choosing whether I should monkey-patch the built-in Array
object, inherit a new Array
class, or keep utilities in a module and have code with mixed method/function calls.
In the same way, even if I were given the Burger
module as a library, I could extend it:
// === BurgerPreset.res ===
let addVegiLayers = burger =>
burger
->Burger.addLayer(Onion)
->Burger.addLayer(Tomato)
->Burger.addLayer(Cucumber)
->Burger.addLayer(Salad)
and use the new “method” afterthen:
let freshburger = Burger.make("Double Fresh Burger")
->Burger.addLayer(SecretSauce)
->BurgerPreset.addVegiLayers
->Burger.addLayer(BeefPatty)
->Burger.addLayer(Cheese)
In case you’re still too annoyed, ReScript offers two possible shortcuts:
// Opening a module brings all its functions
// to the scope of the current one
open Burger
// Module aliases useful for more compact code
// still leaving the trails to the origin
module BP = BurgerPreset
let freshburger = make("Double Fresh Burger")
->addLayer(SecretSauce)
->BP.addVegiLayers
->addLayer(BeefPatty)
->addLayer(Cheese)
Immutable data
Although nothing in the OOP paradigm forces you to change the values of objects’ fields, this is the default way to do the job when using classes. A method accesses fields of this
instance and changes their values. Or it calls another method on a nested child object that changes its values, etc. In other words, OOP traditionally mutates data associated with objects on method calls.
In contrast, the default way in FP languages is to hold on to data that never changes, the immutable data. If you want to change the value of one field, you don’t. Instead, you clone the data you want to change, keeping values for everything the same, except for the fields you want to change. Retake a look at our topping function:
let addLayer = (burger, layer) =>
switch burger.layers->ArrayX.last {
| Some(last) =>
let first =
burger.layers
->Array.slice(~offset=0, ~len=burger.layers->Array.length - 1)
// 👇 Clone!
{
...burger,
layers: Array.concatMany([first, [layer], [last]]),
}
| None =>
// 👇 Clone!
{ ...burger, layers: [layer] }
}
The ...
operator in ReScript clones a record copying all values over, except for the fields specified explicitly. So, the addLayer
function takes a burger
, makes a new one which looks exactly like the original but with the additional layer, then throws the original one to a trash bin. I’d say it’s the direct opposite of OOP encapsulation, and this is the authentic way of FP.
let kingburger =
Burger.make("Special King Burger") // make burger #1
->Burger.addLayer(SecretSauce) // make burger #2, throw away #1
->Burger.addLayer(Cheese) // make burger #3, throw away #2
->Burger.addLayer(Onion) // make burger #4, throw away #3
Yes, I know, it’s strange to throw away a burger and make a new one from scratch just to add a slice of cheese. Gordon Ramsay probably didn’t get it, so he failed to become a programmer (that’s good, actually). However, immutability has a massive effect on programs' simplicity and reliability for us, developers. Working with immutable data structures, you don’t even touch the problem of a shared state which is the source of so many bugs. Before changing a field, you don’t think which other system parts you can affect and how they will behave after that. You don’t think about inconsistent and incomplete data updates in a multithreading environment. You don’t think about orphan nested objects. You just don’t have to think broader than the function you’re writing or reviewing. Immutable data reduces so much stress.
Everything has a cost, and the cost of immutability is performance. But the performance isn’t hit to an extent you might imagine. With guarantees of recursive immutability, creating a clone of a complex and deeply nested object is effectively done by creating one shallow copy at the outermost nesting level. All nested objects are reused in the copy because they cannot change anyway. So, cloning is cheap in most cases.
And when absolutely required, ReScript offers escape hatches. Namely, the mutable
keyword can be applied to a record field declaration. Also, the standard library provides some in-place modification functions for potentially heavy operations. Such functions are explicitly named with caution (for example, stableSortInPlaceBy
) and return unit
(that is, “nothing”) to forbid further pipeline-style processing that could introduce implicit mutable dependencies. When you’re in the danger zone of conventional chaotic imperative programming, ReScript shows this apparently at the level of the language syntax and standard library design.
No null references
Not obviously related to object-oriented programming or encapsulation in particular, there’s a curse in programming familiar to every developer. The billion-dollar mistake, the null reference. Yes, null pointers were introduced way before OOP, but I’m sure mainstream OOP languages like C++, Java, C#, then JavaScript ultimately escalated the problem to a historical extent. That’s because OOP is built around the concept of objects and that objects should be passed around somehow every time. They are passed by reference (aka pointer) and the actual object behind this reference can be—well—the real object, or it can be a bomb which will crash the program once touched 🍔💥
ReScript makes “undefined is not a function” impossible. Let’s take a look at our function one more time:
let addLayer = (burger, layer) =>
switch burger.layers->ArrayX.last {
| Some(last) =>
let first =
burger.layers
->Array.slice(~offset=0, ~len=burger.layers->Array.length - 1)
{
...burger,
layers: Array.concatMany([first, [layer], [last]]),
}
| None =>
{ ...burger, layers: [layer] }
}
First, because ReScript has no null references, you can be 100% sure that the arguments (burger
and layer
) are indeed valid data values, neither can be null
/undefined
. So the program will never crash operating on burger.layers
. Also, the layers array can never accidentally get a null layer that will be a time bomb ready to explode later. Beef, tomato, null, cheese, anyone?
Next, ReScript makes the possibility of an error obvious using one of the idiomatic functional programming mechanics. For example, in our case, ArrayX.last
returns an option that can be some value or none if the array is empty. It sounds similar to what JavaScript does anyway, but there’s a vital difference. You are forced to check both outcomes; otherwise, ReScript compiler barks on you with an error.
Ironic enough, this enforcement made it apparent that the same function implemented earlier in JavaScript is incorrect: it won’t add anything if a burger object has no layers. It should not happen in our simplistic model but inevitably will occur in a real system during its evolution.
Again, there are escape hatches for the cases when you know what you do. ReScript has exceptions and unsafe routines when they are necessary. Such functions are conventionally named with precautionary suffixes like lalaExn
, lalaUnsafe
to warn you about the slippery floor.
Part 2: Abstraction
Abstraction is an OOP feature allowing you to hide implementation details of an object. You’re given an abstraction along with a well-defined interface, and you use it through this interface without thinking how it works under the hood. Let’s see again at our JavaScript class:
class Burger {
constructor(title) {
this.layers = [];
this.layers.push(new BreadRoll());
this.layers.push(new BeefPatty());
this.layers.push(new BreadRoll());
this.title = title;
}
addLayer(layer) {
this.layers.splice(this.layers.length - 1, 0, layer);
}
}
let cheeseburger = new Burger("Cheeseburger");
cheeseburger.addLayer(new Cheese());
It’s transparent that any object of type Burger
has a field named layers
, and that field is an array. It’s not obvious though, if I’m, as an object user, allowed to tweak or even access this field directly. After all, nothing can stop me from messing up layers:
cheeseburger.layers.shift();
Now we have a burger without bread on the bottom, which is unacceptable for our app. To solve the problem, OOP languages allow hiding some fields and methods of an object, making them private for the outside world. C++, C#, Java have class member keyword specifiers; Python, JavaScript recommend following a convention of starting private property names from an underscore _
. Modern JS also allows using hash #
prefix to mark a field private, so we’d better define our class this way:
class Burger {
#layers;
constructor(title) {
this.#layers = [];
this.#layers.push(new BreadRoll());
this.#layers.push(new BeefPatty());
this.#layers.push(new BreadRoll());
this.title = title;
}
addLayer(layer) {
this.#layers.splice(this.#layers.length - 1, 0, layer);
}
}
let cheeseburger = new Burger("Cheeseburger");
cheeseburger.addLayer(new Cheese());
cheeseburger.#layers.shift(); // error!
Now, no one outside the Burger
methods can shuffle the layers. It’s better protected from entering an invalid state now.
Can we hide implementation details in functional programming as well? Easy. Not talking about all FP languages, ReScript has a couple of features that perfectly solve the problem. They are:
- interface files / module signatures
- opaque types
Earlier in the article, we implemented a Burger
module in the Burger.res
source file. Now we can add a Burger.resi
file next to Burger.res
to define the API of this module, effectively limiting how a consumer can use the module from the outside world:
// === Burger.resi ===
type t
let make: string => t
let addLayer: (t, Layer.t) => t
Note that we declared the t
type in this interface file but didn’t provide any details of its underlying structure. That is an opaque type. Having this restriction, a user can’t create arbitrary data values, possibly violating business rules. The only way to make a new burger now is the make
function: you give it a string (the title), you get your burger. Likewise, we declare the signature of addLayer
function.
If we’d add a new function, constant, type definition, or whatever to the Burger.res
implementation file now, they won’t be available anywhere outside of the Burger
module. You must also add them to the interface file to express the public “export” intent.
In the example, we’ve created a module and then declared its interface. In practice, most of the time, I do the reverse: first, create an interface, and only after that write down the implementation. Focusing on the interface rather than implementation details at the first step forces you to imagine and design the best and cleanest API for your mini-library (consider modules are mini-libraries). And only after the well-shaped framework is ready, you complete it with minimally required implementation. Such workflow automatically makes you follow KISS and YAGNI principles.
Now we’ve hidden all the details behind a module signature. I’d say we’ve hidden too much. It’s no longer possible to get a burger name or layers put so far. Let’s fix it and evolve our signature:
// === Burger.resi ===
type t
let make: string => t
// We don’t allow a burger to be renamed after construction,
// but of course, we provide a way to get the given name
let title: t => string
// Get all layers. As long as we follow immutability requirements,
// do whatever you want with the result, it won’t affect the
// underlying burger data
let layers: t => array<Layer.t>
let addLayer: (t, Layer.t) => t
A simple and clear API, isn’t it? It’s time to fill the gaps in the implementation, and that’s trivial:
// === Burger.res ===
/* ... */
let title = burger => burger.title
let layers = burger => burger.layers
I found this pattern of making all record types opaque and publishing just a minimal set of data getters/updaters super-typical for domain objects modeling. With only techniques shown up to this point, you can go very far, and probably your app does not require anything further.
Part 3: Inheritance
OOP offers a mechanism of class extension when a new class declares it is based on some other class. In this case, the derived class inherits all the properties and methods of the base class, then adds new stuff over this base. So, whenever we have several classes derived from the same base, we can be sure they all provide the goodness declared in the base class.
Inheritance expresses the “is a” relation:
- Button is a UI Component
- Cat is an Animal
- Car is a Vehicle
In our restaurant app, besides burgers, we could also serve cocktails. They both, burgers and cocktails, should be present in a menu where’s it is required to show their title, photo, and price. That title, photo, and price are properties they have in common because any such object “is a” product. However, the construction procedure differs; hence we have different object classes. Here’s a possible class hierarchy:
In JavaScript, the hierarchy could be expressed like this:
class Product {
##hhDiscount;
constructor(imageUrl, price, hhDiscount, title) {
this.imageUrl = imageUrl;
this.price = price;
this.#hhDiscount = hhDiscount;
this.title = title
}
discountForHappyHour() {
this.price *= 1 - this.#hhDiscount;
}
}
class Burger extends Product {
constructor(imageUrl, price, hhDiscount, title) {
super(imageUrl, price, hhDiscount, title);
// ... add the basic layers ...
}
addLayer(layer) {
// ...
}
}
class Cocktail extends Product {
constructor(imageUrl, price, hhDiscount, title) {
super(imageUrl, price, hhDiscount, title);
}
mix(drink, volume) {
// ...
}
}
Now, given a list of products, whether burgers or cocktails, a system can render a menu using the common fields and the method to compute a happy-hour price.
The traditional question: how can I express inheritance in a functional programming paradigm? You don’t! Inheritance, like most practices in programming, is an ephemeral concept. You don’t inherit classes for the sake of inheritance; you’re trying to solve problems. And the problem inheritance tries to solve is providing a common ground across different entities. Let’s focus on that.
OOP has a proven principle that any inheritance can be replaced with composition. This is useful because, in general, FP languages have no common inheritance mechanisms, but the composition is something built into their DNA. So, to the practice, how can we express Product
, Burger
, and Cocktail
in ReScript to render a menu of available items and keep the difference in construction? Bonus obstacle to overtake JS OOP inheritance: we already have the Burger
module from above, we are happy with it, we don’t want to change anything there.
First, let’s model our menu render service:
// === Menu.resi ===
let render: array<Product.t> => Image.t
OK, we need a product, here it is:
// === Product.resi ===
type t
let make:
(
~title: string,
~imageUrl: string,
~price: Money.t,
~discount: float,
) => t
let title: t => string
let imageUrl: t => string
let price: t => Money.t
let happyHourPrice: t => Money.t
Good. But isn’t the product is too abstract? Yep, we’ve lost any traces of what the item is and how it is constructed. Let’s fix it:
// === Product.resi ===
type t
type kind =
| Burger(Burger.t) // 100% reuse
| Cocktail(Cocktail.t)
let make:
(
~title: string,
~imageUrl: string,
~price: Money.t,
~discount: float,
kind: kind,
) => t
let title: t => string
let imageUrl: t => string
let price: t => Money.t
let happyHourPrice: t => Money.t
let kind: t => kind
Here I use the thing any FP language provides: an algebraic data type (ADT), known as variant in ReScript. It’s a straightforward yet powerful concept. A value of a variant is strictly one of the enumerated cases along with the payload value(s) specified in parens. In this case, the product kind can be either a Burger
with Burger.t
payload we’ve implemented earlier or a Cocktail
with Cocktail.t
payload.
Now, whenever I deal with a value of Product.kind
type, I’m forced to explain all the variants to the compiler, otherwise it will bark on me:
let isAllowedBefore18 = prodKind =>
switch prodKind {
| Burger(_) => true
| Cocktail(c) => !(c->Cocktail.containsAlcohol)
}
To recap, what was the fuss all about? To abstract burgers and cocktails enough so that the Menu
module could render a nice menu image for our restaurant without thinking much about what particular item is actually. Can we do it now? Definitely!
let cheeseburger = Burger.make()->Burger.addLayer(Cheese)
// ... other instnances ...
// Most likely these would come from a DB,
// but many great things start with hardcode :)
let summerMenu = [
Product.make(
~title="Cheeseburger",
~imageUrl="https://example.com/f562e1f4.jpg",
~price=2.95->Money.eur,
~discount=0.5,
Burger(cheeseburger)
),
Product.make(
~title="Holy King Burger",
~imageUrl="https://example.com/ab1a63a0.jpg",
~price=4.95->Money.eur,
~discount=0.5,
Burger(holyburger)
),
Product.make(
~title="Nonlynchburg Lemonade",
~imageUrl="https://example.com/b585a3c4.jpg",
~price=1.95->Money.eur,
~discount=0.25,
Cocktail(lemonade)
),
Product.make(
~title="B52",
~imageUrl="https://example.com/8a5066aa.jpg",
~price=3.95->Money.eur,
~discount=0,
Cocktail(b52)
),
]
Menu.render(summerMenu)->Team.sendToReview
If I were reading this text 10-15 years ago, I would complain: “— Bullshit! It’s hardcode! The generalized entity has to know all the concrete specifications, inflexible, cannot work!” The reality is that you can’t create an abstraction over an abstraction within an abstraction to model everything in the world. The actual business requirements evolve and show our mental models of classifying things become wrong at some point most of the time.
The good news is the world is simple, actually! If you know you’re making software to manage burgers and cocktails only (OK, a product owner would add maybe appetizers and salads later), it’s perfectly fine to be explicit about it. If you know there will be hundreds of product kinds, go ahead and inverse the structure: let the specific types provide a ProductDescription
instead of keeping specific types inside a Product
. Be flexible, yet simple!
And again, for the most complex scenarios, ReScript offers effective mechanisms like module functors to do metaprogramming. I don’t want to touch them in this article. They can make miracles more impressive than OOP tricks. And if you’d apply them just for a case, your code will become a hocus-pocus: fun for your mates, less fun to solve the problems. Everything has pros and cons.
Part 4: Polymorphism
The last pillar of OOP is subtyping polymorphism also known as virtual methods or inherited methods overloading. The purpose is the following. You can be given a reference to an object that you think is an instance of some class (let’s call it Base
) and call its method (e.g. doJob
). But under the cover—and you neither know it nor want to know—this object can have a type of another class inherited from the Base
(let’s call it Derived
). In this case, instead of the code defined in Base.doJob
, the program will execute the code of Derived.doJob
.
Before C-style classes came to JavaScript in ES6, I would say web devs rarely used OOP polymorphism because the JS-native prototype chain inheritance is too brain-bending for a casual developer. However, it was always a casual tool in other languages to delegate and split various problems. Now it is in JS as well. Imagine generating a minimalistic HTML menu for a given product list. The JavaScript code might be:
class Product {
/* ... */
// Returns an HTML snippet to render a minimalistic
// menu item in the following style:
//
// ***
// Classic Omelet
// (Eggs, Cheese, Onion, Parsley)
// ***
menuItemHtml() {
return [
"<dt>",
this.title,
"</dt>",
"<dd>",
"(",
this.ingredientsString(),
")",
"</dd>",
].join("\n");
}
ingredientsString() {
return "Chef recipe";
}
}
class Burger extends Product {
/* ... */
ingredientsString() {
return (
this
.layers
// exclude bread on the top and bottom as implied
.slice(1, -1)
.map(l => l.title)
.join(", ")
);
}
}
class Cocktail extends Product {
/* ... */
ingredientsString() {
return (
this
.drinks
.map(d => d.title + " " + d.volume + "ml")
.join(" / ")
);
}
}
function menuHtml(products) {
return [
"<dl>",
products.map(p => p.menuItemHtml()),
"</dl>"
].join("\n");
}
Here we have the ingredientsString
method, which is polymorphic. It should give the customer an idea of what he orders. The method can be used on its own, but in particular, it is called by the base class Product.menuItemHtml
to generate the whole menu item markup used elsewhere while menu rendering. The trick with polymorphism is handy because the final result for burgers and cocktails is similar but different in detail. And method overloading can express this requirement in OOP.
How can we express such polymorphism in ReScript? You know the answer: “we don’t!” Again, polymorphism is a synthetic concept employed to solve particular problems, not to use polymorphism on its own, right? All we need is to solve the given problem using the tools available. Variants to the rescue again! I even think it’s too similar to dealing with inheritance to the point of boring:
// === Product.res ===
/* ... */
// Yes, boring dispatching based on the product kind
let ingredientsString = product =>
switch product->kind {
| Burger(b) => b->Burger.ingredientsString
| Cocktail(c) => c->Cocktail.ingredientsString
}
let menuItemHtml = product =>
[
"<dt>",
product->title,
"</dt>",
"<dd>",
"(",
product->ingredientsString,
")",
"</dd>",
]
->Js.Array2.joinWith("\n");
And our burger:
// === Burger.res ===
/* ... */
let ingredientsString = burger =>
burger
->layers
->Array.slice(~offset=1, ~len=burger.layers->Array.length - 2)
->Array.map(Layer.title)
->Js.Array2.joinWith(", ")
And cocktails:
// === Cocktail.res ===
/* ... */
let ingredientsString = cocktail =>
cocktail
->drinks
->Array.map(
((drink, volume)) =>
Drink.title ++ " " ++ volume->Volume.value(#ml) ++ "ml"
)
->Js.Array2.joinWith(" / ")
Boring? Well, yes. Unscalable? Not quite. Of course, when you have a dozen virtual methods, it could become tedious to add switch
-based dispatching again and again. However, I cannot remember a single case when this particular point became boilerplate. First, it’s rare to have a really wide inheritance graph with all classes having their very specific method implementations: in most cases, they are all the same, and only 1 of 10 has something uncommon to say. Second, suppose you absolutely want inheritance polymorphism without dispatch boilerplate. In that case, ReScript offers module functors and first-class modules to achieve it, and I’m still ignoring them in the article because they are ninja-weapon for other issues, I bet it. Third…
Which came earlier: the chicken or the egg? In our case, both should also know about HTML. So the question is going to expand! Which came earlier: the chicken, the egg, or the HTML?! What the hell should an egg think about its presentation on a menu? Should an egg be an expert in HTML, or maybe in PDF or SVG? Hell, no! For so many times, I saw objects that were too smart about the context they live in as I’m giving a high five to the famous quote.
Because the problem with object-oriented languages is they’ve got all this implicit environment that they carry around with them. You wanted a banana but what you got was a gorilla holding the banana and the entire jungle.
—Joe Armstrong, creator of Erlang programming language
The subtyping polymorphism is a beautiful idea that often does not sustain real-world requirements. In the example above, I’d group stuff related to the HTML-menu generation to a separate module leaving the essence untouched.
// === HtmlMenu.res ===
// Hmm… not so dull. All rendering in a single place.
// This module is self-sufficient for solving the rendering problem.
// The original modules are free to remain dumb.
let ingredientsString = product =>
// A potentially long switch that nevertheless allows you to imagine
// and compare the outcome of different kinds of products
switch product->kind {
| Burger(b) =>
b
->layers
->Array.slice(~offset=1, ~len=b.layers->Array.length - 2)
->Array.map(Layer.title)
->Js.Array2.joinWith(", ")
| Cocktail(c) =>
c
->drinks
->Array.map(
((drink, volume)) =>
Drink.title ++ " " ++ volume->Volume.value(#ml) ++ "ml"
)
->Js.Array2.joinWith(" / ")
}
let make = products =>
[
"<dt>",
product->Product.title,
"</dt>",
"<dd>",
"(",
product->ingredientsString,
")",
"</dd>",
]
->Js.Array2.joinWith("\n");
Now, everything related to the HTML menu is nicely grouped in a dedicated module. Easy to read, easy to reason about, easy to change.
What’s wrong with OOP
Nothing. It is overpriced, though. OOP is given to us as a universal solution to all problems in mainstream development. Sure, you can go arbitrarily far just sticking to object-oriented patterns. The question is efficiency and development experience. Besides OOP, other worlds do exist. I’m not saying they are perfect, but we deserve to know the options. Luckily, alternative concepts leak into the mainstream world from time to time and become famous. Take React, for example; I’d say it is an object-oriented antipode; it differs a lot from UI frameworks that were popular before. I’m glad it got traction.
The same is about ReScript. It’s a practical language for real-world development, albeit with a (relaxed) functional paradigm. ReScript also has lightweight JavaScript interop, so it’s easy to mix ReScript parts into an existing JS codebase and vice versa. Take your scale: if your wants for code reliability, simplicity, and robustness overweight the risk of employing new technology, give a chance to the functional programming with ReScript. BTW, I’m not affiliated with the ReScript team anyhow; I’m just a humble, proud user 😌