Skip to content

Commit 3fb0fa0

Browse files
committed
Blog post about unified operators
1 parent 35b77d3 commit 3fb0fa0

File tree

1 file changed

+268
-0
lines changed

1 file changed

+268
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
---
2+
author: rescript-team
3+
date: "2025-04-05"
4+
title: "Rethinking Operators: How ReScript Simplifies Arithmetic and Beyond"
5+
badge: roadmap
6+
description: |
7+
Discover how unified operators in ReScript v12 simplify arithmetic, reduce syntax noise — plus, a glimpse into the future roadmap.
8+
---
9+
10+
## Introduction
11+
12+
For upcoming ReScript v12, we're upgrading common arithmetic operators to be "Unified Operators"
13+
14+
This means that we can now use a single infix operator syntax for multiple numeric types, and even for string concatenation.
15+
16+
```res
17+
let add_int = 1 + 2
18+
let add_float = 1.0 + 2.0
19+
let concat_string = "Hello" + ", World!"
20+
```
21+
22+
[Try in the playground](https://rescript-lang.org/try?version=v12.0.0-alpha.10&module=esmodule&code=DYUwLgBAhgJjD6BLAdpAvBAjBA1BATAFCiSwIBmwA9lOlgHQAMuBTx4EAxlcp7fAGcwAJxQBzCBgBEACRDBqUllIA0EAOpVhwGAEIphIA)— it just works since `v12.0.0-alpha.5`. We don't need `+.` and `++` anymore. 🥳
23+
24+
This post covers both the reasoning behind the change and what’s next on the roadmap. If you're interested in the implementation details, you’ll find them in [the pull request](https://github.com/rescript-lang/rescript/pull/7057).
25+
26+
## Problems in operators
27+
28+
Until v12, the operator syntax had a few notable problems.
29+
30+
### Unwanted syntax gap
31+
32+
Having different operators for each type is not familiar to JavaScript users, and not even allowing overloading can feel strange to most programmers.
33+
34+
This is tricky in the real world. Because JavaScript's default number type is `float`, not `int`, ReScript users have to routinely deal with awkward syntax like `+.`, `-.`, `*.`, `%.`.
35+
36+
Some operators are available only as functions. Instead of `<<` and `>>`, we use unfamiliar names like `lsl` and `asr`.
37+
38+
### Infix explosion 💥
39+
40+
ReScript has multiple "add operator" syntaxes for every primitive type.
41+
42+
```res
43+
let add_int = 1 + 2
44+
let add_float = 1.0 +. 2.0
45+
let concat_string = "Hello" ++ ", World!"
46+
```
47+
48+
We ran into the same issue again when we added `bigint` support.
49+
50+
What other operator syntax could we introduce to add two bigint values? There were suggestions like `+,`, `+!`, `+n`, but the team never felt confident in any of them, so we just hid them inside the `BigInt` module definition instead of introducing new syntax.
51+
52+
It was inconvenient because we had to shadow the definition every time.
53+
54+
```res
55+
let add_bigint = {
56+
open BigInt!
57+
1n + 2n
58+
}
59+
```
60+
61+
Every time we introduce a new primitive type (who knows!), we face the same issue across all arithmetic operators.
62+
63+
### Hidden risk of polymorphism
64+
65+
Then why do we not use the same pretty operators everywhere as JavaScript?
66+
67+
```res
68+
let compare_int = 1 < 2
69+
let compare_float = 1.0 < 2.0
70+
```
71+
72+
```js
73+
let compare_int = true
74+
let compare_float = true
75+
```
76+
77+
And this won't be compiled
78+
79+
```res
80+
let invalid = 1 < 2.0
81+
// ~~~
82+
// [E] Line 4, column 12:
83+
// This has type: float
84+
// But it's being compared to something of type: int
85+
//
86+
// You can only compare things of the same type.
87+
//
88+
// You can convert float to int with Belt.Float.toInt.
89+
// If this is a literal, try a number without a trailing dot (e.g. 20).
90+
```
91+
92+
Because ReScript only intentionally supports monomorphic operations, `(int, int) => int` in this case. Users have to perform type conversions explicitly where necessary.
93+
94+
While it's tempting to allow full operator overloading or polymorphism like JavaScript or TypeScript, we intentionally avoid it to preserve predictable type inference and runtime performance guarantees.
95+
96+
However, comparisons are actually exceptional ones. Let's summon the polymorphism.
97+
98+
```res
99+
let compare_poly = (a, b) => a < b
100+
```
101+
102+
```js
103+
import * as Primitive_object from "./stdlib/Primitive_object.js";
104+
105+
let compare_poly = Primitive_object.lessthan;
106+
```
107+
108+
As both operands `a` and `b` are inferred as "open type", it turned it into a "runtime primitive" that takes any type and performs a struct comparison at runtime.
109+
110+
This is a design decision to support comparisons for arbitrary record or tuple types, but it is not ideal. The runtime primitive is not well optimized and too expensive for common arithmetic operations.
111+
112+
## Unified operators
113+
114+
Unlike polymorphic operators, unified operators don't use runtime primitives at all. Instead, they modify the compiler to translate specific operators to existing compile-time primitives.
115+
116+
More specifically, the following rules are added to the primitive translation process.
117+
118+
> Before handling a primitive, if the primitive operation matches the form of `lhs -> rhs -> result` or `lhs -> result`
119+
>
120+
> 1. If the `lhs` type is a primitive type, unify the `rhs` and the `result` type to the `lhs` type.
121+
> 2. If the `lhs` type is not a primitive type but the `rhs` type is, unify `lhs` and the `result` type to the `rhs` type.
122+
> 3. If both `lhs` type and `rhs` type is not a primitive type, unify the whole types to the `int`.
123+
124+
It changes the type inference like
125+
126+
```res
127+
let t1 = 1 + 2 // => (int, int) => int
128+
let t2 = 1. + 2. // => (float, float) => float
129+
let t3 = "1" + "2" // => (string, string) => string
130+
let t4 = 1n + 2n // => (bigint, bigint) => bigint
131+
132+
let fn1 = (a, b) => a + b // (int, int) => int
133+
let fn2 = (a: float, b) => a + b // (float, float) => float
134+
let fn3 = (a, b: float) => a + b // (float, float) => float
135+
136+
let inv1 = (a: int, b: float) => a + b // => (int, int) => int
137+
// ^ error: cannot apply float here, expected int
138+
```
139+
140+
Then, in IR, it is translated to the corresponding compile-time primitive based on the unified type.
141+
142+
This approach has been inspired by an awesome language [F#](https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/symbol-and-operator-reference/arithmetic-operators#operators-and-type-inference), which is also originated from OCaml.
143+
144+
> The use of an operator in an expression constrains type inference on that operator. Also, the use of operators prevents automatic generalization, because the use of operators implies an arithmetic type. In the absence of any other information, the F# compiler infers `int` as the type of arithmetic expressions.
145+
146+
The rules are limited to only specific primitive types and operators. Perhaps this seems inflexible since it is an ad hoc rule and not part of the formal type system.
147+
148+
But this is enough for us as it practically improves our DX while being **100% backward compatible**.
149+
150+
## Further improvements
151+
152+
The unified operators are already a huge DX improvement for ReScript users — but there’s even more to come!
153+
154+
### Reduced internal complexity
155+
156+
By normalizing how primitive operators are added and managed, it also lowers maintenance overhead. A couple of new operators are actually being added by new community contributors [@MiryangJung](https://github.com/MiryangJung) and [@gwansikk](https://github.com/gwansikk)
157+
158+
### Support most JavaScript operators
159+
160+
We are working to support more unified operators to close the syntax gap with JavaScript.
161+
162+
In the ReScript v12, most familiar JavaScript operators should work as is. Not only arithmetic operators, but also bitwise and shift operators.
163+
164+
- Remainder operator (`%`) - [#7152](https://github.com/rescript-lang/rescript/pull/7152)
165+
- Exponentiation operator (`**`) - [#7153](https://github.com/rescript-lang/rescript/pull/7153)
166+
- Bitwise operators (`~`, `^`, `|`, `&`) - [#7216](https://github.com/rescript-lang/rescript/pull/7216) (only XOR for now)
167+
- Shift operators (`<<`, `>>`, `>>>`) - [#7183](https://github.com/rescript-lang/rescript/pull/7183)
168+
169+
### Better comparison operators
170+
171+
The comparison behavior described above has not changed.
172+
173+
The comparability of records and tuples is a very useful property when dealing with data structures. However, relying on the runtime type information is not an ideal solution.
174+
175+
Since record types are much broader than primitive types, we need a new approach beyond the unified operators.
176+
177+
The compiler fully understands the structure of each record type so it can generate optimized code. Imagine Rust's `#[derive(Eq)]` but for ReScript.
178+
179+
```res
180+
@deriving([compare, equals])
181+
type person = {
182+
name: string,
183+
}
184+
185+
// Implicitly derived unified comparison operators for the `person` type.
186+
external \"person$compare": (person, person) => int = "%compare"
187+
external \"person$equals": (person, person) => bool = "%equals"
188+
```
189+
190+
```javascript
191+
function person$compare(a, b) {
192+
return a.name.localeCompare(b.name);
193+
}
194+
195+
function person$equals(a, b) {
196+
return a.name === b.name;
197+
}
198+
```
199+
200+
The compiler can perform the same specialization used in unified operators and generate code where the comparison operation is used. So `(a :> person) < b` is expected to be `person$compare(a, b) < 0` or fully inlined as it is less complex than a certain threshold.
201+
202+
The example is over-simplified, but it should work equally well with more complex, nested structures or sum types.
203+
204+
### Efficient React memoization
205+
206+
One possible use case for generated comparison operators is React apps.
207+
208+
Using complex types in production apps can result in significant performance degradation, as ReScript ADTs are not compatible with React's memoization behavior.
209+
210+
```res
211+
module MyComponent = {
212+
type payload = {
213+
// ...
214+
}
215+
216+
type state =
217+
| Idle(payload)
218+
| InProgress(payload)
219+
| Done(payload)
220+
221+
@react.component
222+
let make = (~state: state) => <></>
223+
}
224+
225+
let myElement = <MyComponent state=Idle(payload) />
226+
```
227+
228+
Because `Idle(...)` creates a new object each time, React's built-in shallow equality check always fails.
229+
230+
If ReScript generates an optimized shallow equality implementation, it could be used with `React.memo` like this:
231+
232+
```res
233+
module MyComponent = {
234+
type payload = {
235+
// ...
236+
}
237+
238+
type state =
239+
| Idle(payload)
240+
| InProgress(payload)
241+
| Done(payload)
242+
243+
@deriving([shallowEquals])
244+
type props = {
245+
state: state,
246+
}
247+
248+
let make = React.memoCustomCompareProps(
249+
({ state }) => <></>,
250+
251+
// It checks tag equality first.
252+
// If the tags are the same, it checks shallow equality of their payload.
253+
\"props$shallowEquals",
254+
)
255+
}
256+
```
257+
258+
The React component is now effectively memoized and more efficient than a hand-written component in TypeScript.
259+
260+
## Conclusion
261+
262+
Simplicity and conciseness remain ReScript's core values, but that doesn't necessarily mean we cannot improve our syntax.
263+
264+
The unified operator fixes the most awkward parts of the existing syntax and lowers the barrier for JavaScript developers to adopt ReScript, bridging the gap between intuitive JavaScript syntax and ReScript’s strong type guarantees.
265+
266+
We continue to explore the path to becoming the best-in-class language for writing high-quality JavaScript applications. We’d love to hear your thoughts — join the discussion on the forum or reach out on social media.
267+
268+
Thanks for reading — and as always, happy hacking!

0 commit comments

Comments
 (0)