Skip to content

Commit 8bcd7bd

Browse files
Brian M Huntclaude
authored andcommitted
Replace overloadOperator with options.strictEquality
Add `defineOption()` to @tko/utils — a plugin-friendly API for registering custom options with side-effect setters on ko.options. @tko/utils.parser uses defineOption to register `strictEquality`: - ko.options.strictEquality = true → == becomes ===, != becomes !== - ko.options.strictEquality = false → default loose equality (default) - Strongly typed via declaration merging on the Options class - Zero runtime cost: swaps function references at config time builds/reference sets `options.strictEquality = true` at setup. builds/knockout uses the default (loose equality). Removes `overloadOperator` (was only used for ==/!=). 6 new tests covering loose/strict modes, toggling, and === isolation. Addresses #290. Alternative to PR #292. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d4aead2 commit 8bcd7bd

6 files changed

Lines changed: 154 additions & 20 deletions

File tree

builds/reference/src/index.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,12 @@ import components from '@tko/utils.component'
2020
import { createElement, Fragment } from '@tko/utils.jsx'
2121
import { JsxObserver } from '@tko/utils.jsx'
2222

23-
import { overloadOperator } from '@tko/utils.parser'
23+
import { options } from '@tko/utils'
2424

2525
declare const BUILD_VERSION: string
2626

27-
/** Overload "evil twins" with strict equivalents */
28-
overloadOperator('==', (a, b) => a === b)
29-
overloadOperator('!=', (a, b) => a !== b)
27+
/** Use === and !== instead of == and != in binding expressions */
28+
options.strictEquality = true
3029

3130
const builder = new Builder({
3231
filters,
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { expect } from 'chai'
2+
3+
import { options } from '@tko/utils'
4+
import { Parser } from '../dist'
5+
6+
function ctxStub(ctx?) {
7+
return {
8+
lookup(v) {
9+
return ctx?.[v]
10+
},
11+
$data: ctx
12+
}
13+
}
14+
15+
function evaluate(expr: string, vars: object = {}) {
16+
const bindings = new Parser().parse(`r: ${expr}`, ctxStub(vars))
17+
return bindings.r()
18+
}
19+
20+
describe('options.strictEquality', function () {
21+
afterEach(function () {
22+
options.strictEquality = false
23+
})
24+
25+
it('defaults to loose equality (==)', function () {
26+
expect(options.strictEquality).to.equal(false)
27+
// 0 == '' is true with loose equality
28+
expect(evaluate('x == y', { x: 0, y: '' })).to.equal(true)
29+
// null == undefined is true with loose equality
30+
expect(evaluate('x == y', { x: null, y: undefined })).to.equal(true)
31+
})
32+
33+
it('switches to strict equality when enabled', function () {
34+
options.strictEquality = true
35+
// 0 === '' is false with strict equality
36+
expect(evaluate('x == y', { x: 0, y: '' })).to.equal(false)
37+
// 0 === 0 is true
38+
expect(evaluate('x == y', { x: 0, y: 0 })).to.equal(true)
39+
// null === undefined is false
40+
expect(evaluate('x == y', { x: null, y: undefined })).to.equal(false)
41+
})
42+
43+
it('defaults to loose inequality (!=)', function () {
44+
// 0 != '' is false with loose equality (both are falsy)
45+
expect(evaluate('x != y', { x: 0, y: '' })).to.equal(false)
46+
})
47+
48+
it('switches to strict inequality when enabled', function () {
49+
options.strictEquality = true
50+
// 0 !== '' is true with strict equality
51+
expect(evaluate('x != y', { x: 0, y: '' })).to.equal(true)
52+
})
53+
54+
it('can be toggled back to loose', function () {
55+
options.strictEquality = true
56+
expect(evaluate('x == y', { x: 0, y: '' })).to.equal(false) // strict
57+
58+
options.strictEquality = false
59+
expect(evaluate('x == y', { x: 0, y: '' })).to.equal(true) // loose again
60+
})
61+
62+
it('does not affect === and !== operators', function () {
63+
expect(evaluate('x === y', { x: 0, y: 0 })).to.equal(true)
64+
expect(evaluate('x === y', { x: 0, y: '' })).to.equal(false)
65+
expect(evaluate('x !== y', { x: 0, y: '' })).to.equal(true)
66+
67+
options.strictEquality = true
68+
// === and !== should behave the same regardless of the setting
69+
expect(evaluate('x === y', { x: 0, y: 0 })).to.equal(true)
70+
expect(evaluate('x === y', { x: 0, y: '' })).to.equal(false)
71+
expect(evaluate('x !== y', { x: 0, y: '' })).to.equal(true)
72+
})
73+
})

packages/utils.parser/src/index.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import operators from './operators'
2-
31
export { default as Parser } from './Parser'
42
export { default as Identifier } from './Identifier'
53
export { default as Arguments } from './Arguments'
@@ -8,9 +6,5 @@ export { default as Node } from './Node'
86

97
export { default as parseObjectLiteral } from './preparse'
108

11-
export function overloadOperator(op: string, fn: (a, b) => any, precedence?: number) {
12-
operators[op] = fn
13-
if (Number.isInteger(precedence)) {
14-
operators[op].precedence = precedence
15-
}
16-
}
9+
// Importing operators registers the strictEquality option on ko.options
10+
import './operators'

packages/utils.parser/src/operators.ts

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { unwrap } from '@tko/observable'
2+
import { defineOption } from '@tko/utils'
23

34
export function LAMBDA() {}
45

@@ -25,6 +26,16 @@ export interface OperatorWithProperties extends OperatorFunction {
2526
export interface Operators {
2627
[key: string]: OperatorWithProperties
2728
}
29+
function looseEqual(a, b) {
30+
return a == b
31+
}
32+
looseEqual.precedence = 10
33+
34+
function looseNotEqual(a, b) {
35+
return a != b
36+
}
37+
looseNotEqual.precedence = 10
38+
2839
const operators: Operators = {
2940
// unary
3041
'@': unwrapOrCall,
@@ -82,13 +93,9 @@ const operators: Operators = {
8293
// TODO: 'in': function (a, b) { return a in b; },
8394
// TODO: 'instanceof': function (a, b) { return a instanceof b; },
8495
// TODO: 'typeof': function (a, b) { return typeof b; },
85-
// equality
86-
'==': function equal(a, b) {
87-
return a == b
88-
},
89-
'!=': function ne(a, b) {
90-
return a != b
91-
},
96+
// equality — default loose comparison; use setStrictEquality(true) for === behavior
97+
'==': looseEqual,
98+
'!=': looseNotEqual,
9299
'===': function sequal(a, b) {
93100
return a === b
94101
},
@@ -207,4 +214,34 @@ operators['call'].precedence = 1
207214
// lambda
208215
operators['=>'].precedence = 1
209216

217+
/**
218+
* When true, == and != in binding expressions use === and !==.
219+
* Swaps the operator function references — zero cost at evaluation time.
220+
*/
221+
function strictEqual(a, b) {
222+
return a === b
223+
}
224+
strictEqual.precedence = 10
225+
226+
function strictNotEqual(a, b) {
227+
return a !== b
228+
}
229+
strictNotEqual.precedence = 10
230+
231+
// Extend the Options type so ko.options.strictEquality is strongly typed
232+
declare module '@tko/utils' {
233+
interface Options {
234+
strictEquality: boolean
235+
}
236+
}
237+
238+
/** Register strictEquality as a configurable option on ko.options */
239+
defineOption('strictEquality', {
240+
default: false,
241+
set(strict: boolean) {
242+
operators['=='] = strict ? strictEqual : looseEqual
243+
operators['!='] = strict ? strictNotEqual : looseNotEqual
244+
}
245+
})
246+
210247
export { operators as default }

packages/utils/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export * from './function'
1212
export * from './string'
1313
export * from './symbol'
1414
export * from './css'
15-
export { default as options } from './options'
15+
export { default as options, defineOption, Options } from './options'
1616

1717
// DOM;
1818
export * from './dom/event'

packages/utils/src/options.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,4 +135,35 @@ export class Options {
135135

136136
const options = new Options()
137137

138+
/**
139+
* Define a custom option on ko.options with an optional side-effect setter.
140+
* Plugins use this to register their own configuration properties.
141+
*
142+
* Must be called before applyBindings — setters run side effects at
143+
* configuration time, not retroactively on already-parsed bindings.
144+
*
145+
* @example
146+
* defineOption('strictEquality', {
147+
* default: false,
148+
* set(strict) { /* swap operator functions *\/ }
149+
* })
150+
* // Then: ko.options.strictEquality = true
151+
*/
152+
export function defineOption<T>(name: string, config: { default: T; set?: (value: T) => void }) {
153+
let _value = config.default
154+
Object.defineProperty(options, name, {
155+
get() {
156+
return _value
157+
},
158+
set(value: T) {
159+
_value = value
160+
config.set?.(value)
161+
},
162+
enumerable: true,
163+
configurable: true
164+
})
165+
// Run the setter with the default value to initialize side effects
166+
config.set?.(_value)
167+
}
168+
138169
export default options

0 commit comments

Comments
 (0)