|
| 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