Apr 11, 2025

Rethinking Operators

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

ReScript Team
Core Development

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.

RES
let 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.

RES
let 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.

RES
let 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?

RES
let compareInt = (a: int, b) => a < b let compareFloat = (a: float, b) => a < b
JS
function compareInt(a, b) { return a < b; } function compareFloat(a, b) { return a < b; }

And this won't be compiled

RES
let 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!

RES
let comparePoly = (a, b) => a < b
JS
import * 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 or lhs -> result

  1. If the lhs type is a primitive type, unify the rhs and the result type to the lhs type.

  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.

  3. If both lhs type and rhs type is not a primitive type, unify the whole types to the int.

It changes the type inference like

RES
let 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 (%) - #7152

  • Exponentiation operator (**) - #7153

  • Bitwise operators (~, ^, |, &) - #7172

  • Shift 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"
JAVASCRIPT
function 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.

RES
module 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:

RES
module 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!

Want to read more?
Back to Overview