Rethinking Operators
Discover how unified operators in ReScript v12 simplify arithmetic, reduce syntax noise — plus, a glimpse into the future roadmap.

Introduction
In the upcoming ReScript v12, we're upgrading common arithmetic operators to "Unified Operators".
This means that we can now use a single infix operator syntax for multiple numeric types, and even for string concatenation.
RESlet addInt = 1 + 2
let addFloat = 1.0 + 2.0
let concatString = "Hello" + ", World!"
Try in the playground— it just works since v12.0.0-alpha.5
. We don't need +.
and ++
anymore. 🥳
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.
Problems in operators
Until v12, the operator syntax had a few notable problems.
Unwanted syntax gap
Using different operators for each type is unfamiliar to JavaScript users, and the lack of operator overloading can feel strange to most programmers.
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 +.
, -.
, *.
, %.
.
Some operators are available only as functions. Instead of <<
and >>
, we use unfamiliar names like lsl
and asr
.
Infix explosion 💥
ReScript has multiple "add operator" syntaxes for every primitive type.
RESlet addInt = 1 + 2
let addFloat = 1.0 +. 2.0
let concatString = "Hello" ++ ", World!"
We ran into the same issue again when we added bigint
support.
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.
It was inconvenient because we had to shadow the definition every time.
RESlet addBigInt = {
open BigInt!
1n + 2n
}
Every time we introduce a new primitive type (who knows?), we run into the same issue with all arithmetic operators.
Hidden risk of polymorphism
So why don’t we just use the same pretty operators everywhere, like in JavaScript?
RESlet compareInt = (a: int, b) => a < b
let compareFloat = (a: float, b) => a < b
JSfunction compareInt(a, b) {
return a < b;
}
function compareFloat(a, b) {
return a < b;
}
And this won't be compiled
RESlet compareNumber = (a: int, b: float) => a < b
// ~
// [E] Line 1, column 46:
// This has type: float
// But it's being compared to something of type: int
//
// You can only compare things of the same type.
//
// You can convert float to int with Belt.Float.toInt.
// If this is a literal, try a number without a trailing dot (e.g. 20).
Because ReScript only intentionally supports monomorphic operations, (int, int) => int
in this case. Users have to perform type conversions explicitly where necessary.
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.
However, comparisons are actually the exception. Time to summon polymorphism!
RESlet comparePoly = (a, b) => a < b
JSimport * as Primitive_object from "./stdlib/Primitive_object.js";
let comparePoly = Primitive_object.lessthan;
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.
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.
Unified operators
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.
More specifically, the following rules are added to the primitive translation process.
Before handling a primitive, if the primitive operation matches the form of
lhs -> rhs -> result
orlhs -> result
If the
lhs
type is a primitive type, unify therhs
and theresult
type to thelhs
type.If the
lhs
type is not a primitive type but therhs
type is, unifylhs
and theresult
type to therhs
type.If both
lhs
type andrhs
type is not a primitive type, unify the whole types to theint
.
It changes the type inference like
RESlet t1 = 1 + 2 // => (int, int) => int
let t2 = 1. + 2. // => (float, float) => float
let t3 = "1" + "2" // => (string, string) => string
let t4 = 1n + 2n // => (bigint, bigint) => bigint
let fn1 = (a, b) => a + b // (int, int) => int
let fn2 = (a: float, b) => a + b // (float, float) => float
let fn3 = (a, b: float) => a + b // (float, float) => float
let inv1 = (a: int, b: float) => a + b // => (int, int) => int
// ^ error: cannot apply float here, expected int
Then, in IR, it is translated to the corresponding compile-time primitive based on the unified type.
This approach is inspired by the awesome language F#, which also originates from OCaml.
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.
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.
But this is enough for us as it practically improves our DX while being 100% backward compatible.
Further improvements
The unified operators are already a huge DX improvement for ReScript users — but there’s even more to come!
Reduced internal complexity
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 and @gwansikk
Support most JavaScript operators
We are working to support more unified operators to close the syntax gap with JavaScript.
In ReScript v12, most familiar JavaScript operators should work as-is — not just arithmetic operators, but also bitwise and shift operators.
Remainder operator (
%
) - #7152Exponentiation operator (
**
) - #7153Bitwise operators (
~
,^
,|
,&
) - #7172Shift operators (
<<
,>>
,>>>
) - #7171
The future of comparison operators
The comparison behavior described above has not changed. The comparability of records and tuples is useful when dealing with data structures. However, relying on the runtime type information is not an ideal solution.
Since record types are much broader than primitive types, we need a new approach beyond the unified operators.
This won't be included in the v12 release, but we'd like to share an idea we're exploring. Imagine Rust's #[derive(Eq)]
but for ReScript. As the compiler fully understands the structure of each record type, it can generate optimized code for each type.
RES@deriving([compare, equals])
type person = {
name: string,
}
// Implicitly derived unified comparison operators for the `person` type.
external \"person$compare": (person, person) => int = "%compare"
external \"person$equals": (person, person) => bool = "%equals"
JAVASCRIPTfunction person$compare(a, b) {
return a.name.localeCompare(b.name);
}
function person$equals(a, b) {
return a.name === b.name;
}
Then, the compiler performs the same specialization used in unified operators and generates 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.
The example is over-simplified, but it should work equally well with more complex, nested structures or sum types.
One possible use case for generated comparison operators is React apps. Using complex types in production apps can result in significant performance degradation, as ReScript ADTs are not compatible with React's memoization behavior.
RESmodule MyComponent = {
type payload = {
// ...
}
type state =
| Idle(payload)
| InProgress(payload)
| Done(payload)
@react.component
let make = (~state: state) => <></>
}
let myElement = <MyComponent state=Idle(payload) />
Because Idle(...)
creates a new object each time, React's built-in shallow equality check always fails.
If ReScript generates an optimized shallow equality implementation, it could be used with React.memo
like this:
RESmodule MyComponent = {
type payload = {
// ...
}
type state =
| Idle(payload)
| InProgress(payload)
| Done(payload)
@deriving([shallowEquals])
type props = {
state: state,
}
let make = React.memoCustomCompareProps(
({ state }) => <></>,
// It checks tag equality first.
// If the tags are the same, it checks shallow equality of their payload.
\"props$shallowEquals",
)
}
The React component is now effectively memoized and more efficient than a hand-written component in TypeScript.
Conclusion
Simplicity and conciseness remain ReScript's core values, but that doesn't necessarily mean we cannot improve our syntax.
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.
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.
Thanks for reading — and as always, happy hacking!