Skip to content

Instantly share code, notes, and snippets.

@rauschma
Last active May 10, 2023 09:53
Show Gist options
  • Star 7 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save rauschma/d4a5b159611f56815af9ea408143938d to your computer and use it in GitHub Desktop.
Save rauschma/d4a5b159611f56815af9ea408143938d to your computer and use it in GitHub Desktop.
Better dynamic type checks

Better dynamic type checks

  • Update 2022-07-10: new approach based on Function.prototype.hasInstance()

Problems

In principle, the rule “typeof is for primitive values, instanceof is for objects” works. There are two exceptions:

  • Checking if a value is an object
  • Cross-realm instance checks

Checking if a value is an object

Neither typeof nor instanceof are good at checking if a value is an object.

Checking if a value is non-primitive via typeof, is complicated:

const isObject = (v) => (
  v !== null && (typeof v === 'object' || typeof v === 'function')
);

instanceof can only be used for objects whose prototype chains end with Object.prototype:

> Object.create(null) instanceof Object
false
> typeof Object.create(null)
'object'

> Object.prototype instanceof Object
false
> typeof Object.prototype
'object'

Cross-realm instance checks

v instanceof C only works if both v and C come from the current realm. In other cases, we can’t generally perform the check, but for some classes, there are workarounds:

  • Array.isArray(v)
  • typeof v === 'function'

Proposal

The proposal introduces two features for cross-realm instance checks:

  • Symbol.typeMarker lets us assign cross-realm symbols as type markers for classes.
    • All built-in classes get type markers.
  • Function.prototype.hasInstance(v) is inherited by all functions and checks if v is an instance of this.

The proposal also introduces one static method:

  • Object.isObject()

These are my first thoughts. I’m not attached to either specific names or many of the details.

The new rules

To check if a value is primitive, use typeof and ===:

const categorize = (v) => ({
  isUndefined: v === undefined,
  isNull: v === null,
  isBoolean: typeof v === 'boolean',
  isNumber: typeof v === 'number',
  isBigint: typeof v === 'bigint',
  isString: typeof v === 'string',
  isSymbol: typeof v === 'symbol',
});

To check if a value v (that may or may not come from another realm) is an instance of a class C, use C.hasInstance(v):

Map.hasInstance(valueFromAnotherRealm)
MyClass.hasInstance(valueFromAnotherRealm)
// Etc.

To determine if a value is an object, use Object.isObject():

> Object.isObject(Object.create(null))
true
> Object.isObject(Object.prototype)
true

More examples: see tests.

ECMAScript Pattern Matching

  • Should the custom matchers proposed by ECMAScript Pattern Matching support Symbol.typeMarker?
//========== Symbol.typeMarker ==========
Object.defineProperties(Symbol, {
typeMarker: {
value: Symbol.for('Symbol.typeMarker'),
writable: false,
enumerable: false,
configurable: false,
},
});
const builtinClasses = [
globalThis.AggregateError,
globalThis.Array,
globalThis.ArrayBuffer,
globalThis.BigInt,
globalThis.BigInt64Array,
globalThis.BigUint64Array,
globalThis.Boolean,
globalThis.DataView,
globalThis.Date,
globalThis.Error,
globalThis.EvalError,
globalThis.FinalizationRegistry,
globalThis.Float32Array,
globalThis.Float64Array,
globalThis.Function,
globalThis.Int8Array,
globalThis.Int16Array,
globalThis.Int32Array,
globalThis.Map,
globalThis.Number,
globalThis.Object,
globalThis.Promise,
// Doesn’t really have instances: globalThis.Proxy,
globalThis.RangeError,
globalThis.ReferenceError,
globalThis.RegExp,
globalThis.Set,
globalThis.SharedArrayBuffer,
globalThis.String,
globalThis.Symbol,
globalThis.SyntaxError,
globalThis.TypeError,
globalThis.Uint8Array,
globalThis.Uint8ClampedArray,
globalThis.Uint16Array,
globalThis.Uint32Array,
globalThis.URIError,
globalThis.WeakMap,
globalThis.WeakRef,
globalThis.WeakSet,
];
for (const builtinClass of builtinClasses) {
const sym = Symbol.for('es.tc39.' + builtinClass.name);
defineProp(builtinClass, Symbol.typeMarker, sym);
defineProp(builtinClass.prototype, sym, true);
}
//========== Function.prototype.hasInstance() ==========
function hasInstance(value) {
const type = this;
if (Object.hasOwn(type, Symbol.typeMarker)) {
return type[Symbol.typeMarker] in value;
}
if (typeof type === 'function' && Object.hasOwn(type, 'prototype')) {
return isPrototypeOf(type.prototype, value);
}
return false;
}
defineProp(Function.prototype, 'hasInstance', hasInstance);
//========== Object.isObject() ==========
const isObject = (v) => v !== null && (typeof v === 'object' || typeof v === 'function');
defineProp(globalThis.Object, 'isObject', isObject);
//========== Helpers ==========
function defineProp(obj, key, value) {
Object.defineProperty(
obj, key, {
value,
writable: true,
enumerable: false,
configurable: true,
}
);
}
const uncurryThis = (theFunc) => Function.prototype.call.bind(theFunc);
const isPrototypeOf = uncurryThis(Object.prototype.isPrototypeOf);
// node --experimental-vm-modules has-instance_test.mjs
import * as assert from 'node:assert/strict';
import './has-instance.mjs';
//========== Object.isObject() ==========
assert.equal(
Object.isObject(Object.create(null)), true
);
assert.equal(
Object.isObject(Object.prototype), true
);
assert.equal(
Object.isObject(null), false
);
assert.equal(
Object.isObject(undefined), false
);
assert.equal(
Object.isObject(123), false
);
//========== Builtin object types ==========
assert.equal(
Array.hasInstance([]), true
);
assert.equal(
Object.hasInstance([]), true
);
//========== Local realm instance checks for user classes ==========
class MyLocalClass {}
assert.equal(
MyLocalClass.hasInstance(new MyLocalClass()), true
);
assert.equal(
MyLocalClass.hasInstance({}), false
);
//========== Cross-realm instance checks ==========
//----- Setting up the foreign realm -----
import * as vm from 'node:vm';
import * as fs from 'node:fs';
async function linker(specifier, referencingModule) {
const src = fs.readFileSync(specifier, {encoding: 'utf-8'});
return new vm.SourceTextModule(
src,
{
context: referencingModule.context,
}
);
}
const foreignContext = vm.createContext({
print: console.log
});
// Experimental API: https://nodejs.org/api/vm.html#class-vmmodule
const foreignModule = new vm.SourceTextModule(
`
import './has-instance.mjs';
export const foreignArray = [];
const CrossRealmClassMarker = Symbol.for('com.2ality.MyCrossRealmClass');
class CrossRealmClass {
static [Symbol.typeMarker] = CrossRealmClassMarker;
get [CrossRealmClassMarker]() {}
}
export const foreignInstance = new CrossRealmClass();
`,
{
context: foreignContext,
}
);
await foreignModule.link(linker);
await foreignModule.evaluate();
const {foreignArray, foreignInstance} = foreignModule.namespace;
//----- Foreign Array -----
assert.equal(
foreignArray instanceof Array, false
);
assert.equal(
Array.isArray(foreignArray), true
);
assert.equal(
Array.hasInstance(foreignArray), true
);
//----- Foreign instance -----
const CrossRealmClassMarker = Symbol.for('com.2ality.MyCrossRealmClass');
class CrossRealmClass {
static [Symbol.typeMarker] = CrossRealmClassMarker;
// Create property CrossRealmClass.prototype[CrossRealmClassMarker]
// (value doesn’t matter, only key):
[CrossRealmClassMarker]() {}
}
assert.equal(
CrossRealmClass.hasInstance(new CrossRealmClass()), true
);
assert.equal(
CrossRealmClass.hasInstance(foreignInstance), true
);
assert.equal(
CrossRealmClass.hasInstance({}), false
);
@madmoizo
Copy link

madmoizo commented Jul 10, 2022

I like the idea!
It seems you have to know the logic behind to make a good use of it though.
Why hasType(v, 'number') and not hasType(v, Number) or hasType(v, Array) and not hasType(v, 'array')?
In a way we are back to the initial problematic: Depending on what kind of type check we want to perform, we have to choose between

@rauschma
Copy link
Author

@madmoizo Alas, primitive types don’t really have “classes” that could be used: new Number(123) is an instance of Number, but 123 isn’t.

But I agree that using strings as types can be confusing. I’ve changed the proposal to reflect that.

@w3nl
Copy link

w3nl commented Jul 12, 2022

Nice!

I never use typeof anymore, but always use constructor check, like:

const a = [1];
a.constructor === Array
a.constructor.name === 'Array'

You can check the constructor or the constructor name.

Works for me with all types (String, Array, Object, Number, Boolean, URL, Date, Function, AsyncFunction, ...), and also custom types.

Check also: https://github.com/hckrnews/validator and https://github.com/hckrnews/objects how i do type checking.

Your example of numbers:

const a = new Number(123)
a.constructor === Number
a.constructor.name === 'Number'

const b = 124
b.constructor === Number
b.constructor.name === 'Number'

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment