Skip to content

Commit 8ca5594

Browse files
Sander Toonenclaude
andcommitted
Make == / != valueOf-aware so DateTime values just work
Equal/notEqual in the core compare two non-null objects by their `valueOf()` result whenever both operands return a primitive distinct from themselves. This mirrors what `<` / `>` / `<=` / `>=` already did via JavaScript's `ToPrimitive` algorithm, and lets values like Luxon `DateTime` and JS `Date` compare by instant without the core ever knowing about either type. Twelve-line change to packages/expreszo/src/operators/binary/comparison.ts: * `equal(a, b)` short-circuits on `a === b`, then for two non-null object operands calls `a.valueOf()` and `b.valueOf()`. If neither result is the object itself (i.e. both have a meaningful primitive form), it returns `av === bv`. Otherwise falls through to false, preserving today's reference-equality semantics for plain objects and arrays. * `notEqual(a, b)` is now `!equal(a, b)`. What this changes: * Two `Date` instances at the same millisecond are now `==`. * Two Luxon `DateTime` values at the same instant are now `==` (when the @pro-fa/expreszo-datetime plugin is registered, since that's the source of the values). * Boxed primitives (new Number(5), new String('x')) compare by their primitive form too. What this does NOT change: * Plain objects ({a:1} == {a:1}) still false — they have no overridden valueOf, so the new branch falls through. * Arrays ([1,2] == [1,2]) still false, same reason. * Primitives keep their existing semantics. * null/undefined handling unchanged. * Object compared to a primitive is still false — no implicit cross-type coercion. No plugin changes required. Users register `dateTimePlugin` exactly as before; all six comparison operators (`<`, `>`, `<=`, `>=`, `==`, `!=`) work on DateTimes (and JS Dates) automatically. Tests: * 9 new tests in packages/expreszo/test/operators/operators-comparison.ts covering Date↔Date, custom-valueOf objects, Date↔custom-valueOf cross-type, plain objects (still false), arrays (still false), NaN-valued Dates (still not equal), object vs primitive, null handling. * 4 new tests in packages/expreszo-datetime/test/plugin.test.ts covering the actual Luxon DateTime path end-to-end and DateTime↔Date cross-type equality. Versions: * @pro-fa/expreszo: 0.6.0 -> 0.6.1 * @pro-fa/expreszo-datetime: 0.2.0 -> 0.2.1, peer-dep range bumped to ^0.6.1 to require the new equality behaviour. Docs: * BREAKING_CHANGES.md gains a 0.6.1 section explaining the change, what's affected, and what isn't. * CHANGELOG.md (datetime) gains a 0.2.1 entry pointing at the core change as the source. * docs/datetime.md "Comparison" section now leads with the operator examples and demotes the named functions to the millisecond-truncated / range / sortable cases. * docs/datetime-integration.md gains an "Operators on DateTime values" section explaining the valueOf duck-typing and noting that arithmetic operators (+, -) are intentionally not covered. Verified: yarn workspaces run lint/type-check/test all green (2200 core + 25 datetime + 12 mcp = 2237 tests). mkdocs build --strict clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c8a1db3 commit 8ca5594

8 files changed

Lines changed: 163 additions & 5 deletions

File tree

docs/datetime-integration.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,22 @@ Functions that **produce** a date return a Luxon `DateTime` so chains stay effic
5454

5555
The full normaliser lives at [`packages/expreszo-datetime/src/normalize.ts`](https://github.com/Pro-Fa/expreszo-typescript/blob/main/packages/expreszo-datetime/src/normalize.ts) and is exported as `toDateTime` / `toDateTimeOrUndefined` for callers who want to do their own conversions outside an expression.
5656

57+
## Operators on DateTime values
58+
59+
As of `@pro-fa/expreszo` 0.6.1, the core comparison operators are `valueOf`-aware: any two non-null objects whose `.valueOf()` returns a primitive distinct from themselves compare by that primitive. So with `dateTimePlugin` registered (or just a JS `Date` in your `variables` map):
60+
61+
```
62+
parseISO('2026-01-01') == parseISO('2026-01-01') // true
63+
parseISO('2026-01-01') < parseISO('2026-02-01') // true
64+
parseISO('2026-01-01') >= parseISO('2026-01-01') // true
65+
```
66+
67+
No operator overrides, no separate plugin — the relational operators (`<`, `>`, `<=`, `>=`) always worked because JS's `ToPrimitive` already calls `valueOf`; `==` and `!=` now follow suit.
68+
69+
Plain objects and arrays still use reference equality, so the change is targeted at value-bearing objects (`Date`, Luxon `DateTime`, boxed primitives) only.
70+
71+
Arithmetic operators (`+`, `-`) are intentionally **not** DateTime-aware — `addDuration`, `subtractDuration`, and `diff` are explicit about units and avoid the "what does `date + 7` mean" footgun.
72+
5773
## Wiring the plugin into the language service
5874

5975
`createLanguageService` accepts the same plugins via a `plugins` option, so completions, hover, and diagnostics see the datetime functions too:

packages/expreszo-datetime/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ All notable changes to `@pro-fa/expreszo-datetime` are documented here.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## 0.2.1
8+
9+
### Improved
10+
11+
`==` and `!=` now compare two `DateTime` values by instant. This isn't a change in this package — it's a side-effect of `@pro-fa/expreszo` 0.6.1 making the core operators `valueOf`-aware. With `dateTimePlugin` registered, **all six comparison operators** (`<`, `>`, `<=`, `>=`, `==`, `!=`) work as expected on DateTimes (and on JS `Date` values from your variables map). No additional wiring needed.
12+
13+
The peer-dependency range now requires `@pro-fa/expreszo` ≥ 0.6.1.
14+
715
## 0.2.0
816

917
### Added

packages/expreszo-datetime/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pro-fa/expreszo-datetime",
3-
"version": "0.2.0",
3+
"version": "0.2.1",
44
"description": "Optional Luxon-backed date/time functions for the @pro-fa/expreszo expression evaluator.",
55
"license": "MIT",
66
"type": "module",
@@ -40,7 +40,7 @@
4040
"plugin"
4141
],
4242
"peerDependencies": {
43-
"@pro-fa/expreszo": "^0.6.0"
43+
"@pro-fa/expreszo": "^0.6.1"
4444
},
4545
"dependencies": {
4646
"luxon": "^3.5.0"

packages/expreszo-datetime/test/plugin.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,35 @@ describe('inspection', () => {
132132
});
133133
});
134134

135+
// Core's `equal`/`notEqual` are valueOf-aware as of @pro-fa/expreszo 0.6.1,
136+
// so two Luxon DateTime instances at the same instant compare equal via the
137+
// existing `==` and `!=` operators. The plugin doesn't need to override
138+
// anything — it just needs to be the source of the DateTime values.
139+
describe('== and != on DateTime values via core operators', () => {
140+
it('two DateTimes at the same instant are ==', () => {
141+
expect(parser().evaluate("parseISO('2026-01-01') == parseISO('2026-01-01')")).toBe(true);
142+
expect(parser().evaluate("parseISO('2026-01-01') != parseISO('2026-01-01')")).toBe(false);
143+
});
144+
145+
it('two DateTimes at different instants are !=', () => {
146+
expect(parser().evaluate("parseISO('2026-01-01') == parseISO('2026-01-02')")).toBe(false);
147+
expect(parser().evaluate("parseISO('2026-01-01') != parseISO('2026-01-02')")).toBe(true);
148+
});
149+
150+
it('a Luxon DateTime equals an equivalent JS Date', () => {
151+
const ms = Date.UTC(2026, 0, 1);
152+
const jsDate = new Date(ms);
153+
expect(parser().evaluate("parseISO('2026-01-01T00:00:00Z') == d", { d: jsDate as unknown as never })).toBe(true);
154+
});
155+
156+
it('relational operators continue to work as before', () => {
157+
expect(parser().evaluate("parseISO('2026-01-01') < parseISO('2026-02-01')")).toBe(true);
158+
expect(parser().evaluate("parseISO('2026-02-01') > parseISO('2026-01-01')")).toBe(true);
159+
expect(parser().evaluate("parseISO('2026-01-01') <= parseISO('2026-01-01')")).toBe(true);
160+
expect(parser().evaluate("parseISO('2026-01-01') >= parseISO('2026-01-01')")).toBe(true);
161+
});
162+
});
163+
135164
describe('end-to-end pipeline', () => {
136165
it('chains construction, arithmetic, and formatting', () => {
137166
expect(

packages/expreszo/BREAKING_CHANGES.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,26 @@
22

33
This document lists breaking changes in the library to help users migrate between versions.
44

5+
## 0.6.1 — `==` / `!=` are now valueOf-aware
6+
7+
`equal` and `notEqual` (the `==` and `!=` operators) now compare two non-null objects by their `valueOf()` result whenever both operands return a primitive distinct from themselves. This mirrors what `<` / `>` / `<=` / `>=` already did via JavaScript's `ToPrimitive` algorithm, and lets values like Luxon `DateTime` and JS `Date` compare by instant without the core knowing about either type.
8+
9+
**What changed:**
10+
11+
- Two `Date` instances at the same millisecond are now `==`. They were `!=` before (reference equality on objects).
12+
- Two Luxon `DateTime` values at the same instant are now `==` (when the `@pro-fa/expreszo-datetime` plugin is registered).
13+
- Boxed primitives — `new Number(5)`, `new String('x')` — also start comparing by their primitive form.
14+
15+
**What did not change:**
16+
17+
- Plain objects (`{a:1} == {a:1}`) still return `false` — they have no overridden `valueOf()`, so the new branch falls through to reference equality.
18+
- Arrays (`[1,2] == [1,2]`) still return `false`, same reason.
19+
- All primitive comparisons (`3 == 3`, `'a' == 'a'`) keep their existing semantics.
20+
- `null` continues to equal only `null`; `undefined` continues to equal only `undefined`.
21+
- Object compared to a primitive is still `false` — no implicit cross-type coercion.
22+
23+
This is technically a behaviour change for code that was relying on `Date == Date` returning `false`. Audit any expression that explicitly compared two date-like objects expecting reference inequality.
24+
525
## 0.6.0 — companion packages and `parser.use(plugin)`
626

727
### `./mcp-server` subpath export removed

packages/expreszo/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pro-fa/expreszo",
3-
"version": "0.6.0",
3+
"version": "0.6.1",
44
"description": "Mathematical expression evaluator",
55
"keywords": [
66
"expression",

packages/expreszo/src/operators/binary/comparison.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,27 @@
44
*/
55

66
export function equal(a: any, b: any): boolean {
7-
return a === b;
7+
if (a === b) return true;
8+
// Two non-null objects with a meaningful `valueOf()` (i.e. one that
9+
// returns something *other than* the object itself) are compared by
10+
// their primitive form. This mirrors what the relational operators
11+
// (`<`, `>`, `<=`, `>=`) already do via JS's `ToPrimitive`, and lets
12+
// values like Luxon `DateTime` and JS `Date` compare by instant
13+
// without the core ever knowing about either type.
14+
// Plain objects and arrays return themselves from `valueOf()`, so
15+
// they still fall through to reference-equality semantics.
16+
if (a !== null && b !== null && typeof a === 'object' && typeof b === 'object') {
17+
const av = a.valueOf();
18+
const bv = b.valueOf();
19+
if (av !== a && bv !== b) {
20+
return av === bv;
21+
}
22+
}
23+
return false;
824
}
925

1026
export function notEqual(a: any, b: any): boolean {
11-
return a !== b;
27+
return !equal(a, b);
1228
}
1329

1430
export function greaterThan(a: any, b: any): boolean | undefined {

packages/expreszo/test/operators/operators-comparison.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,4 +138,73 @@ describe('Comparison Operators TypeScript Test', () => {
138138
expect(parser.evaluate('3 <= 3')).toBe(true);
139139
});
140140
});
141+
142+
// Equality is valueOf-aware: any two non-null objects whose `.valueOf()`
143+
// returns a primitive distinct from themselves compare by that primitive.
144+
// This makes `==` and `!=` line up with how `<`/`>`/`<=`/`>=` already
145+
// behave (via ToPrimitive), and lets values like JS `Date` and Luxon
146+
// `DateTime` work without core knowing about either type.
147+
describe('== / != on objects with a meaningful valueOf', () => {
148+
const ms = Date.UTC(2026, 0, 1);
149+
150+
it('two JS Date instances at the same instant are ==', () => {
151+
const d1 = new Date(ms);
152+
const d2 = new Date(ms);
153+
expect(parser.evaluate('a == b', { a: d1 as never, b: d2 as never })).toBe(true);
154+
expect(parser.evaluate('a != b', { a: d1 as never, b: d2 as never })).toBe(false);
155+
});
156+
157+
it('two JS Date instances at different instants are !=', () => {
158+
const d1 = new Date(ms);
159+
const d2 = new Date(ms + 1);
160+
expect(parser.evaluate('a == b', { a: d1 as never, b: d2 as never })).toBe(false);
161+
expect(parser.evaluate('a != b', { a: d1 as never, b: d2 as never })).toBe(true);
162+
});
163+
164+
it('two custom objects with the same numeric valueOf are ==', () => {
165+
const a = { valueOf: () => 42 };
166+
const b = { valueOf: () => 42 };
167+
expect(parser.evaluate('a == b', { a: a as never, b: b as never })).toBe(true);
168+
});
169+
170+
it('a JS Date and a custom valueOf object compare by their primitive', () => {
171+
const a = new Date(ms);
172+
const b = { valueOf: () => ms };
173+
expect(parser.evaluate('a == b', { a: a as never, b: b as never })).toBe(true);
174+
});
175+
176+
it('plain objects without an overridden valueOf still use reference equality', () => {
177+
const a = { x: 1 };
178+
const b = { x: 1 };
179+
expect(parser.evaluate('a == b', { a: a as never, b: b as never })).toBe(false);
180+
expect(parser.evaluate('a != b', { a: a as never, b: b as never })).toBe(true);
181+
// Same reference is still ==
182+
expect(parser.evaluate('a == a', { a: a as never })).toBe(true);
183+
});
184+
185+
it('arrays still use reference equality', () => {
186+
const a = [1, 2, 3];
187+
const b = [1, 2, 3];
188+
expect(parser.evaluate('a == b', { a: a as never, b: b as never })).toBe(false);
189+
});
190+
191+
it('two invalid Dates (NaN-valued) are not ==', () => {
192+
const a = new Date(NaN);
193+
const b = new Date(NaN);
194+
expect(parser.evaluate('a == b', { a: a as never, b: b as never })).toBe(false);
195+
expect(parser.evaluate('a != b', { a: a as never, b: b as never })).toBe(true);
196+
});
197+
198+
it('object compared to a primitive is not ==', () => {
199+
const a = new Date(ms);
200+
expect(parser.evaluate('a == ms', { a: a as never, ms })).toBe(false);
201+
expect(parser.evaluate('a != ms', { a: a as never, ms })).toBe(true);
202+
});
203+
204+
it('null still equals only null', () => {
205+
const a = new Date(ms);
206+
expect(parser.evaluate('a == n', { a: a as never, n: null })).toBe(false);
207+
expect(parser.evaluate('n == n', { n: null })).toBe(true);
208+
});
209+
});
141210
});

0 commit comments

Comments
 (0)