Skip to content

Latest commit

 

History

History
295 lines (243 loc) · 4.84 KB

File metadata and controls

295 lines (243 loc) · 4.84 KB

Issue of Brand checking

class X {
	#x
	constructor(value = 0) {
		this.#x = value
	}
	equal(that) {
			return this.#x === that.#x
		}
	}
}
new X(1).equal(new X(1)) // true
new X(1).equal(new X(2)) // false
new X().equal({}) // throw! oops...

Most JS programmers familiar with duck type, so if they want to do "branding", they would like to write if (that[brand]) ... or more strict form that[brand] !== undefined or brand in that.

Unfortunately, with this.#x you have no way to do such test

Of coz we can use try

class X {
	...
  equal(that) {
		try {
			return this.#x === that.#x
		} catch {
			return false
		}
	}
}

But such abusing of try is not very desirable. Eventually we realize it's just type check. So most programmers will like to write:

class X {
	...
  equal(that) {
		if (!(that instanceof X)) return false
		return this.#x === that.#x
	}
}

Not bad. But there are holes.

const x = new X()
x.equal({}) // false
\
const x1 = { __proto__: X.prototype }
x.equal(x1) // throw!
\
const x2 = Object.create(x)
x.equal(x2) // throw!
\
const x3 = new Proxy(x, {})
x.equal(x3) // throw!

So the only safe way is try

Finally you will write code like that to bypass "branding"

class Foo {
	#x
	#y
	constructor(x, y) {
		this.#x = x
		this.#y = y
	}
	equal(that) {
			return this.#x === suppressBrand(() => that.#x)
				&& this.#y === suppressBrand(() => that.#y)
		}
	}
}
\
const fail = Symbol()
function suppressBrand(retrievePrivateField) {
	try {
		return retrievePrivateField()
	} catch {
		return fail
	}
}

Do we really need strict brand checking by default?

opt-in

const brandFoo = new WeakSet()
function checkFoo(o) {
	if (!brandFoo.has(o)) throw new TypeError()
}
class Foo {
	constructor() {
		brandFoo.add(this)
	}
	method1() {
		checkFoo(this)
		...
	}
	method2(that) {
		checkFoo(this)
		checkFoo(that)
		...
	}
}
class Foo {
	constructor() {
		if (new.target === Foo) brandFoo.add(this)
	}
	...
}
@brand
class Foo {
	method1() {
		...
	}
	method2(that) {
		this.brandCheck(that)
		...
	}
	@noBrand method3() {
		...
	}
}

Much flexible!

End

Normally, JS programmers will like to write:

class X {
	#x
	constructor(value = 0) {
		this.#x = value
	}

	equal(that) {
			return this.#x === that.#x
		}
	}
}

new X(1).equal(new X(1)) // true
new X(1).equal(new X(2)) // false
new X().equal({}) // throw! oops...

Most JS programmers familiar with duck type, so if they want to do "branding", they would like to write if (that[brand]) ... or more strict form that[brand] !== undefined or brand in that. Unfortunately, with this.#x you have no way to do such test.

Of course we can use try.

class X {
	...
  equal(that) {
		try {
			return this.#x === that.#x
		} catch {
			return false
		}
	}
}

But such abusing of try is not very desirable. Eventually we realize it's just type check! So most programmers will write:

class X {
	...
  equal(that) {
		if (!(that instanceof X)) return false
		return this.#x === that.#x
	}
}

Not bad. But there are holes.

const x = new X()
x.equal({}) // false

const x1 = { __proto__: X.prototype }
x.equal(x1) // throw!

const x2 = Object.create(x)
x.equal(x2) // throw!

So basically you have to also customize instanceof behavior:

class X {
	...
	static [Symbol.hasInstance](instance) {
		if (!this.prototype.isPrototypeOf(instance)) return false
		try {
			instance.#x
			return true
		} catch {
			return false
		}
	}

  equal(that) {
		if (!(that instanceof X)) return false
		return this.#x === that.#x
	}
}

Unfortunately, you need to rewrite [Symbol.hasInstance]

Both this.#x or this::x have the branding problem.

The author of the class can make sure foo will never be undefined, so for otherX is not X (which otherX[private.foo] is undefined), it just return false. (But note we should use === instead of == unless you really want weak-type conversion)

But such pattern not scale well in more complex cases.

class Node {
  const id = generateId()
  let parent = null
  const children = []
  const descendant = new Map()

  add(child) {
    if (child::parent) child::parent::remove(child)
    this::children.push(child)
    child::parent = this
    for (let node = this; node != null; node = node::parent) {
      node::descendant.set(id, child)
    }
  }
  remove(child) {
    if (child::parent !== this) throw new Error('no such child!')
    const i = this::children.findIndex(child)
    this::children.splice(i, 1)
    for (let node = this; node != null; node = node::parent) {
      node::descendant.delete(id)
    }
    child::parent = null
  }
  getById(id) {
    return this::descendant.get(id) || null
  }
}

Assume we want also support add(anyObject), how we can do it?

Branding is not good thing for such requirements. The only choice is: