Disclaimer: This article is opinionated, and it's assuming you've already looked into ReScript a bit. I wrote this to defend my judgment on using or not using ReScript in some projects. If you are curious more, visit the ReScript Forum and see more opinions.
Are you ever hesitant about adopting ReScript, or have you tried it and been frustrated? I will give you a realistic guide for adopting ReScript in projects.
What's ReScript?
ReScript is an language, that compiles to JavaScript, providing a superior type system, fast compilation, and type-checking. It offers best-in-class JavaScript interoperability compared to many similar languages.
What's the difference from TypeScript?
According to its official docs, ReScript and TypeScript have pretty different approaches to code.
TypeScript's design aims to express code that is already written in JS. In a sense, it goes beyond the scope of a type system. As a result, TypeScript's type system is more powerful and flexible than any other type systems. It has the power to express even messy and ambiguous models.
ReScript, on the other hand, aims to support clean data models. It has first-class ADT(Algebraic Data Type) support, useful for modeling, and pattern matching for expressing declarative logic, all of those based on its sound type system.
Let me demonstrate a simple state machine in ReScript.
// Counter.res
type data = { count: int }
type state =
| Paused(data)
| Running(data)
type event =
| Start
| Pause
| Resume
| Increase
| Decrease
| Reset
let init = () => Paused({ count: 0 })
let next = (state, event) => {
// Compiler understand what type you want here, without additional annotations
switch (state, event) {
| (Paused(data), Start | Resume) => Running(data)
| (Running({ count }), Increase) => Running({ count: count + 1 })
| (Running({ count }), Decrease) => Running({ count: count - 1 })
| (Running({ count }), Pause) => Paused({ count: count })
| (_, Reset) => init()
}
}
Beautiful, right?
And the compiler is super smart so you can immediately noticed actually some unhandled patterns there:
You forgot to handle a possible case here, for example:
(Running({count: _, _}), Start | Resume)
| (Paused(_), Pause | Increase | Decrease)
However, in some cases, a sound type system may also imply sacrificing some expressiveness to the model. In return for the features, you can't express everything you can express in JavaScript/TypeScript with ReScript.
When ReScript hurts
If you use lodash/fp
or Ramda.js to compose functions, or like using ts-pattern to simulate pattern matching in TypeScript, you might also be interested in languages like ReScript.
But adopting is often difficult.
Because of the differences mentioned above, you may encounter challenges when trying to express your intentions, or you may feel overwhelmed when trying to bind frequently used external types.
For example:
@send external beginPath: t => unit = "beginPath"
@send external closePath: t => unit = "closePath"
@send external fill: t => unit = "fill"
@send external stroke: t => unit = "stroke"
@send external clip: t => unit = "clip"
@send external moveTo: (t, ~x: float, ~y: float) => unit = "moveTo"
@send external lineTo: (t, ~x: float, ~y: float) => unit = "lineTo"
@send
external quadraticCurveTo: (t, ~cp1x: float, ~cp1y: float, ~x: float, ~y: float) => unit =
"quadraticCurveTo"
@send
external bezierCurveTo: (
t,
~cp1x: float,
~cp1y: float,
~cp2x: float,
~cp2y: float,
~x: float,
~y: float,
) => unit = "bezierCurveTo"
@send
external arcTo: (t, ~x1: float, ~y1: float, ~x2: float, ~y2: float, ~r: float) => unit = "arcTo"
@send
external arc: (
t,
~x: float,
~y: float,
~r: float,
~startAngle: float,
~endAngle: float,
~anticw: bool,
) => unit = "arc"
@send external rect: (t, ~x: float, ~y: float, ~w: float, ~h: float) => unit = "rect"
@send external isPointInPath: (t, ~x: float, ~y: float) => bool = "isPointInPath"
/* Path2D */
type path2d
@new external newPath2D: string => path2d = "Path2D"
@send external fillPath2D: (t, path2d) => unit = "fill"
@send external strokePath2D: (t, path2d) => unit = "stroke"
/* Text */
@set external font: (t, string) => unit = "font"
@set external textAlign: (t, string) => unit = "textAlign"
@set external textBaseline: (t, string) => unit = "textBaseline"
@send
external fillText: (t, string, ~x: float, ~y: float, ~maxWidth: float=?, @ignore unit) => unit =
"fillText"
@send
external strokeText: (t, string, ~x: float, ~y: float, ~maxWidth: float=?, @ignore unit) => unit =
"strokeText"
@send external measureText: (t, string) => measureText = "measureText"
@get external width: measureText => float = "width"
/* Rectangles */
@send external fillRect: (t, ~x: float, ~y: float, ~w: float, ~h: float) => unit = "fillRect"
@send external strokeRect: (t, ~x: float, ~y: float, ~w: float, ~h: float) => unit = "strokeRect"
@send external clearRect: (t, ~x: float, ~y: float, ~w: float, ~h: float) => unit = "clearRect"
This is part of the rescript-webapi binding code to use the Canvas API.
If your initial experience with ReScript involved dealing with a graphics library interface rather than your own application logic, you may have abandoned it quickly.
The major concern
You end up in a dilemma. Which is better: ReScript or TypeScript?
You might consider the choice of language to be a significant concern; You might be concerned that opting for ReScript could lead to an irreversible decision, making it challenging for newcomers to understand the codebase or even hindering the hiring process.
But for all your worries, language doesn't have as major an impact as you might think. What really matters is what you express in the language, your business.
If your business is complicated, it will look complicated even if you express it in the most appropriate language, and if your business is niche, it will be difficult to hire new people even if you use the most popular language.
Enter the "clean room"
When a business and the software represents it become complex, developers have a hard time keeping up with it.
Even if TypeScript’s type system tolerates the situation, it doesn't help much. This is not because it is essentially a problem caused by language, but because the mixing different models and its expressions.
You have to orchestrate models and dependencies well for deealing with growing complexity.
Worth paying attention to the famous approach "Clean Architecture". I'm not to say it's the answer, but it's worth noting that it isolates the business model and puts it to the "core" of the entire dependencies.
In this approach, by definition, there are no external dependencies on the "core" module. Without worrying about the outside world, you can just model your business and build logic from scratch. This is one of the few places where you can adopt DSL(Domain Specific Language) with confidence.
You may still use TypeScript here, but this is where ReScript really shines. In my experience, ReScript shows a few times better expressiveness than TypeScript when dealing with pure business logic.
Let me show examples:
// Entity_Organization.res
// ...
@genType
type data = {
name: string,
label: string,
owner: memberId,
members: array<memberId>,
}
@genType
type state =
| Active(data)
| Archived(data)
@genType
type event =
| Created({date: Date.t, by: memberId, name: string, label: string})
| MemberAdded({date: Date.t, by: option<memberId>, member: memberId})
| MemberRemoved({date: Date.t, by: option<memberId>, member: memberId})
@genType
type error =
| MemberAlreadyJoined({by: option<memberId>, member: memberId})
| CannotRemoveOwner({by: option<memberId>, ownerMember: memberId})
| CannotRemoveSelf({member: memberId})
| CannotModifyArchived({by: option<memberId>})
@genType
type t = {
id: id,
seq: int,
events: array<event>,
state: option<state>,
}
@genType
let make = (id, ~state=?, ~seq=0, ()) => {
id,
seq,
events: [],
state,
}
module Logic = Abstract.Logic.Make({
type id = id
type state = state
type t = t
type event = event
type error = error
let make = make
})
let logic: Logic.t = (t, event) => {
switch (t, event) {
// bunch of patterns
| ({state: Some(Archived(_))}, MemberAdded({by})) =>
Error(CannotModifyArchived({by}))
| ({state: Some(Active({members})}, MemberAdded({member}))
if members->Array.some(existing => existing == member) =>
Error(MemberAlreadyJoined({by, member}))
| ({state: Some(Acvite(state))}, MemberAdded({member})) =>
Ok({
...t,
events: t.events->Array.concat([event]),
state: Some(Active({
...state,
members: state.members->Array.concat([member]),
})),
})
}
// ...
}
@genType
let addMember = (t, ~date, ~by, ~member) => {
let event = MemberAdded({
date,
by,
member,
})
logic->Logic.run(t, event)
}
Bonus, features as labeled arguments and HM type inference save you the hassle of declaring signatures of dependencies.
// Service_Organization.res
@genType
let addMemberToOrganization = async (
// Just name of dependencies, let the compiler work for you.
~findMember,
~findOrganization,
~memberId,
~organizationId,
~by,
~date,
) => {
switch await Promise.all2((findMember(memberId), findOrganization(organizationId))) {
| (Some(member), Some(organization)) =>
switch (
organization->Organization.addMember(~member=member.Member.id, ~by, ~date),
member->Member.joinToOrganization(~organization=organization.id, ~date),
) {
| (Ok(organization), Ok(member)) => Ok({"organization": organization, "member": member})
| (organizationResult, memberResult) =>
Error(
#AggregatedError({
"member": memberResult->Util.someError,
"organization": organizationResult->Util.someError,
}),
)
}
| (member, organization) =>
Error(
#InvalidParameter({
"member": member->Option.map(member => member.id),
"organization": organization->Option.map(organization => organization.id),
}),
)
| exception Js.Exn.Error(exn) => Error(#IOError({"exn": exn}))
}
}
See? There is no need to declare types such as interface AddMemberToOrganizationDeps { ... }
.
This is very convinient on React components too
@genType
@react.component
let make = (~onClick) => {
// ... useSomthing
<button onClick> {message} </button>
}
By just adding labels findOrganization
or onClick
and using it, the ReScript compiler infers actual types from context.
Then add @genType
. The ReScript compiler will generate correct TypeScript definitions for you. So you can use the Service_Organization
module written in ReScript on your GraphQL server written in TypeScript, using Pothos, Prisma, whatever you want.
import * as OrganizationService from 'core/Service_Organization.gen.ts';
builder.mutationFields((t) => ({
addMemberToOrganization: t.field({
type: AddMemberToOrganizationOutputSchema,
args: {
input: t.arg({ type: AddMemberToOrganizationInputSchema, required: true }),
},
authScopes: {
activeMember: true,
},
async resolve(_root, args, ctx) {
const result = await OrganizationService.addMemberToOrganization({
findMember: ctx.app.repo.findMember,
findOrganization: ctx.app.repo.findOrganization,
memberId: args.input.memberId,
organizationId: args.input.organizationId,
by: ctx.req.currentMember?.id,
date: Date.now(),
});
if (result.tag === 'Error') {
// handle errors
}
// ... other works
return ctx.app.eventStore.publishAny(result.value);
},
}),
}));
This is a very basic inversion of control.
It's nice that you don't need additonal decorators or container like NestJS for dependency injection. Everything is plain function and it can be checked at compile time by ReScript and TypeScript.
These advantages can be applied regardless of whether it is backend or frontend.
There are several architectural patterns for UI applications. It's not an exact classification, but I categorize it into Elm style (aka the Elm Architecture) and Cycle.js (aka Reactive programming or Data-flow graph) style.
Elm is actually a language very similar to ReScript (as the same ML family), and the background to its success was that it provided this architectural pattern from the very beginning. Elm sandboxes the core UI logic, and interaction with the system (e.g. DOM APIs) is provided through the Elm runtime.
You can choose a similar architectural pattern for your UI application. Use ReScript at its core, and use TypeScript to build your own Elm runtime.
Or you may be more familiar with Redux. In Redux, all dirty works is handled by middleware. Then the rest of the UI can be written in ReScript without any inconvenience.
Choosing a general-purpose language
Counterintuitively, languages are more convenient when they represent a limited surface of a model. Although you may fear that it will be difficult to read and difficult to understand if the language varies. However, TOML code is actually more concise and easier to understand than TypeScript code for the "configuration" model
Another example of how convenient a language can be is Mint. It represents declarative, component-based UI, styling, and state management in a very clean way.
Some of the special-purpose languages are not that simple, some of them restrict which interpreter or runtime (VM) you can use. From the perspective of an expression and interpretation system, frameworks like Svelte and Vue are also able to be categorized as languages.
Here, the advantages of TypeScript and ReScript are similar; "bring your own interpreter". Both have no restrictions other than specifying JavaScript as the target. And the JavaScript is universal.
They can also be used for special purposes, like DSL by deliberately limiting their expressiveness.
ReScript can be used to express React components with formatted type signature.
ts-pattern can be called a (kinda) DSL and its interpreter for declarative logic (ReScript is obviously better for this). And so on.
They may not be the languages with the best representation of the model, but they can provide a level of representation that the developer is satisfied with.
In my experience, the closer to the core, the more powerful ReScript is, and the further out, the more convenient TypeScript is. But why don't pick both?
When need quick and dirty
Not all software has this kind of code architecture. Code architectures are difficult to maintain. If critical parts are not properly isolated, they can easily become messy.
However, actually, in most cases, messily wired monolithic code is good enough. In these codebases, flexible languages win.
Is ReScript flexible enough? Perhaps not (yet)
But even lack flexibility, it can be sufficiently productive with a proper "framework" (it's like your own interpreter) for its purpose.
IMO this is one of the missing pieces in the current ReScript ecosystem. Something like ReScript-first full-stack webapp framework (binding to Next.js is not sufficient)
However, the ReScript language and ecosystem are still growing steadily, and I'm sure we will get there in the near future.
Conclusion
You don't need to use ReScript for the whole of your project. The same goes for TypeScript. Where the language makes more sense, use it. The use of multiple languages in any complex project is more common than you might think.
Before embracing a language like ReScript, first evaluate your domain model and the appropriate architecture. Try using ReScript in spaces that have as few dependencies as possible and deal with pure business logic.
Not necessarily, but it makes you happier.
As you become more comfortable and confident using ReScript, you can try ReScript with other external models as well. Help ReScript cover more by sharing it with the community.