refactor: replace instanceof with symbol-based brands on class objects

This commit is contained in:
Glen Whitney 2025-12-12 02:03:00 -08:00
parent dbee782c4f
commit b32d4a2058
6 changed files with 48 additions and 20 deletions

View file

@ -19,7 +19,7 @@ describe('boolean type functions', () => {
}) })
it('converts any type to boolean', () => { it('converts any type to boolean', () => {
for (const T in math.types) { for (const T in math.types) {
if (T instanceof Type) assert(boolean.resolve([T])) if (Type.holds(T)) assert(boolean.resolve([T]))
} }
}) })
}) })

View file

@ -1,13 +1,23 @@
const implementationsBrand = Symbol()
export class Implementations { export class Implementations {
constructor(impOrImps) { constructor(impOrImps) {
if (Array.isArray(impOrImps)) { if (Array.isArray(impOrImps)) {
this.matchers = impOrImps this.matchers = impOrImps
} else this.matchers = [impOrImps] } else this.matchers = [impOrImps]
this[implementationsBrand] = true
} }
// Returns true if entity is an Implementations
static holds(entity) {return entity[implementationsBrand]}
} }
const igBrand = Symbol()
export class ImplementationsGenerator { export class ImplementationsGenerator {
constructor(f) { constructor(f) {
this.generate = f this.generate = f
this[igBrand] = true
} }
// Returns true if entity is an ImplementationsGenerator
static holds(entity) {return entity[igBrand]}
} }

View file

@ -1,5 +1,7 @@
import ArrayKeyedMap from 'array-keyed-map' import ArrayKeyedMap from 'array-keyed-map'
const typeBrand = Symbol() // invisible outside this file
// Generic types are callable, so we have no choice but to extend Function // Generic types are callable, so we have no choice but to extend Function
export class Type extends Function { export class Type extends Function {
constructor(f, options = {}) { constructor(f, options = {}) {
@ -19,6 +21,7 @@ export class Type extends Function {
} }
}) })
this[typeBrand] = true
this.test = f this.test = f
// we want property `from` to end up as an array of Matchers: // we want property `from` to end up as an array of Matchers:
this.from = options.from this.from = options.from
@ -57,15 +60,16 @@ export class Type extends Function {
return rewired return rewired
} }
toString() { toString() {return this.typeName || `[Type ${this.test}]`}
return this.typeName || `[Type ${this.test}]`
} // Returns true if entity is a Type
static holds(entity) {return entity[typeBrand]}
} }
export const Undefined = new Type( export const Undefined = new Type(
t => typeof t === 'undefined', t => typeof t === 'undefined',
{zero: undefined, one: undefined, nan: undefined}) {zero: undefined, one: undefined, nan: undefined})
export const TypeOfTypes = new Type(t => t instanceof Type) export const TypeOfTypes = new Type(t => Type.holds(t))
export const Unknown = new Type(() => true) // Danger, do not merge! export const Unknown = new Type(() => true) // Danger, do not merge!
Unknown._doNotMerge = true Unknown._doNotMerge = true
@ -74,7 +78,7 @@ export const OneOf = (...types) => {
if (!types.length) { if (!types.length) {
throw new RangeError('cannot choose OneOf no types at all') throw new RangeError('cannot choose OneOf no types at all')
} }
const nonType = types.findIndex(T => !(T instanceof Type)) const nonType = types.findIndex(T => !Type.holds(T))
if (nonType >= 0) { if (nonType >= 0) {
throw new RangeError( throw new RangeError(
`OneOf can only take type arguments, not ${types[nonType]}`) `OneOf can only take type arguments, not ${types[nonType]}`)
@ -106,7 +110,7 @@ export const ReturnType = f => f.returns ?? Unknown
export const whichType = typs => Returns(TypeOfTypes, item => { export const whichType = typs => Returns(TypeOfTypes, item => {
for (const type of Object.values(typs)) { for (const type of Object.values(typs)) {
if (!(type instanceof Type)) continue if (!Type.holds(type)) continue
if (type.test(item)) return type.refine(item, whichType(typs)) if (type.test(item)) return type.refine(item, whichType(typs))
} }
let errorMsg = '' let errorMsg = ''

View file

@ -81,10 +81,10 @@ export class TypeDispatcher {
// For special cases like types, config, etc, we can wrap // For special cases like types, config, etc, we can wrap
// a function in ImplementationsGenerator to produce the thing // a function in ImplementationsGenerator to produce the thing
// we should really merge: // we should really merge:
if (val instanceof ImplementationsGenerator) val = val.generate() if (ImplementationsGenerator.holds(val)) val = val.generate()
// Now dispatch on what sort of thing we are supposed to merge: // Now dispatch on what sort of thing we are supposed to merge:
if (val instanceof Type) { if (Type.holds(val)) {
if (val._doNotMerge) { if (val._doNotMerge) {
throw new TypeError(`attempt to merge unusable type '${val}'`) throw new TypeError(`attempt to merge unusable type '${val}'`)
} }
@ -105,13 +105,13 @@ export class TypeDispatcher {
// Everything else we coerce into Implementations and deal with // Everything else we coerce into Implementations and deal with
// right here: // right here:
if (val instanceof Matcher) val = new Implementations(val) if (Matcher.holds(val)) val = new Implementations(val)
if (Array.isArray(val)) val = new Implementations(val) if (Array.isArray(val)) val = new Implementations(val)
if (isPlainFunction(val)) { if (isPlainFunction(val)) {
throw new RangeError( throw new RangeError(
`function value for ${key} must be merged within a 'match' call`) `function value for ${key} must be merged within a 'match' call`)
} }
if (!(val instanceof Implementations)) { if (!Implementations.holds(val)) {
val = new Implementations(match(Passthru, val)) val = new Implementations(match(Passthru, val))
} }
@ -307,7 +307,7 @@ export class TypeDispatcher {
} }
if (generatingDeps if (generatingDeps
&& typeof result === 'object' && typeof result === 'object'
&& !(result instanceof Type) && !Type.holds(result)
) { ) {
return DependencyRecorder(result, key, this, bhvix) return DependencyRecorder(result, key, this, bhvix)
} }
@ -354,7 +354,7 @@ export class TypeDispatcher {
behave.set(bhvix, item) behave.set(bhvix, item)
if (generatingDeps if (generatingDeps
&& typeof item === 'object' && typeof item === 'object'
&& !(item instanceof Type) && !Type.holds(item)
) { ) {
return DependencyRecorder(item, key, this, bhvix) return DependencyRecorder(item, key, this, bhvix)
} }
@ -469,7 +469,8 @@ const DependencyRecorder = (object, path, repo, bhvix) => new Proxy(object, {
const result = Reflect.get(target, prop, receiver) const result = Reflect.get(target, prop, receiver)
// pass internal methods through, as well as resolve calls, // pass internal methods through, as well as resolve calls,
// since we record dependencies within the latter: // since we record dependencies within the latter:
if (prop.startsWith('_') if (typeof prop === 'symbol'
|| prop.startsWith('_')
|| prop === 'resolve' || prop === 'resolve'
|| (typeof result === 'function' && 'isDispatcher' in result) || (typeof result === 'function' && 'isDispatcher' in result)
) { ) {
@ -484,7 +485,7 @@ const DependencyRecorder = (object, path, repo, bhvix) => new Proxy(object, {
// dependencies on its properties (e.g. math.config.predictable) // dependencies on its properties (e.g. math.config.predictable)
// So proxy the return value, except for types, which must maintain // So proxy the return value, except for types, which must maintain
// strict referential identity: // strict referential identity:
if (typeof result === 'object' && !(result instanceof Type)) { if (typeof result === 'object' && !Type.holds(result)) {
return DependencyRecorder(result, newPath, repo, bhvix) return DependencyRecorder(result, newPath, repo, bhvix)
} else return result } else return result
} }
@ -508,7 +509,7 @@ const DependencyWatcher = (object, path, ixList, repo) => new Proxy(object, {
get(target, prop, receiver) { get(target, prop, receiver) {
// Only thing we need to do is push the watching down // Only thing we need to do is push the watching down
const result = Reflect.get(target, prop, receiver) const result = Reflect.get(target, prop, receiver)
if (typeof result === 'object' && !(result instanceof Type)) { if (typeof result === 'object' && !Type.holds(result)) {
const newPath = [path, prop].join('.') const newPath = [path, prop].join('.')
return DependencyWatcher(result, newPath, ixList, repo) return DependencyWatcher(result, newPath, ixList, repo)
} }

View file

@ -1,7 +1,12 @@
import {ReturnType, Type, Undefined, Unknown} from './Type.js' import {ReturnType, Type, Undefined, Unknown} from './Type.js'
import {isPlainFunction} from './helpers.js' import {isPlainFunction} from './helpers.js'
const tpBrand = Symbol()
export class TypePattern { export class TypePattern {
constructor() {
this[tpBrand] = true
}
match(_typeSequence, _options={}) { match(_typeSequence, _options={}) {
throw new Error('Specific TypePatterns must implement match') throw new Error('Specific TypePatterns must implement match')
} }
@ -10,6 +15,9 @@ export class TypePattern {
} }
equal(other) {return other.constructor === this.constructor} equal(other) {return other.constructor === this.constructor}
toString() {return 'Abstract Pattern (?!)'} toString() {return 'Abstract Pattern (?!)'}
// Returns true if entity is a TypePattern
static holds(entity) {return entity[tpBrand]}
} }
class MatchTypePattern extends TypePattern { class MatchTypePattern extends TypePattern {
@ -91,8 +99,8 @@ class PredicatePattern extends TypePattern {
} }
export const pattern = patternOrSpec => { export const pattern = patternOrSpec => {
if (patternOrSpec instanceof TypePattern) return patternOrSpec if (TypePattern.holds(patternOrSpec)) return patternOrSpec
if (patternOrSpec instanceof Type) { if (Type.holds(patternOrSpec)) {
return new MatchTypePattern(patternOrSpec) return new MatchTypePattern(patternOrSpec)
} }
if (Array.isArray(patternOrSpec)) return new SequencePattern(patternOrSpec) if (Array.isArray(patternOrSpec)) return new SequencePattern(patternOrSpec)
@ -206,11 +214,16 @@ export const needsCollection = (template) => {
return 'actual' in template return 'actual' in template
} }
const matcherBrand = Symbol()
export class Matcher { export class Matcher {
constructor(spec, facOrBehave) { constructor(spec, facOrBehave) {
this.pattern = pattern(spec) this.pattern = pattern(spec)
this.does = facOrBehave this.does = facOrBehave
this[matcherBrand] = true
} }
// Returns true if entity is a matcher
static holds(entity) {return entity[matcherBrand]}
} }
export const match = (spec, facOrBehave) => new Matcher(spec, facOrBehave) export const match = (spec, facOrBehave) => new Matcher(spec, facOrBehave)

View file

@ -7,8 +7,8 @@ import {match, Matcher, TypePattern} from '../TypePatterns.js'
describe('Core helpers', () => { describe('Core helpers', () => {
it('defines what Matchers are', () => { it('defines what Matchers are', () => {
const matcher = match([TypeOfTypes, Undefined], -3) const matcher = match([TypeOfTypes, Undefined], -3)
assert(matcher instanceof Matcher) assert(Matcher.holds(matcher))
assert(matcher.pattern instanceof TypePattern) assert(TypePattern.holds(matcher.pattern))
}) })
it('detects plain objects', () => { it('detects plain objects', () => {