Skip to content

Instantly share code, notes, and snippets.

@dschnare
Last active February 19, 2020 14:58
Show Gist options
  • Save dschnare/4c886e6a7281875e7a090403afdd6812 to your computer and use it in GitHub Desktop.
Save dschnare/4c886e6a7281875e7a090403afdd6812 to your computer and use it in GitHub Desktop.
Functional runtime type checking functions
/**
* Get the type name for a value.
*
* @example type(null) // 'Null'
* @example type(undefined) // 'Undefined'
* @example type(45) // 'Number'
* @example type('Hello') // 'String'
* @example type({}) // 'Object'
* @example type(/hi/) // 'RegExp'
* @example type(new Date()) // 'Date'
* @example type(new Set()) // 'Set'
* @example type(new Map()) // 'Map'
* @example type([]) // 'Array'
*/
const type = value => {
if (value === null) {
return 'Null'
}
if (value === undefined) {
return 'Undefined'
}
return Object.prototype.toString.call(value).slice(8, -1)
}
/**
* Test a value against a type expression.
*
* A type expression can be one of the following:
* - a type name as returned from calling type()
* - a constructor function
* - a Protocol object
*
* @example islike('Number', 45) // true
* @example islike('String', '45') // true
* @example islike('Boolean', false) // true
* @example islike('Boolean', {}) // false
* @example islike(Record({ age: 'Number' }), { age: 67 }) // true
*/
const islike = (typeExpr, value) => {
switch (type(typeExpr)) {
case 'Protocol':
return !!typeExpr.test(value)
case 'Function':
return value instanceof typeExpr
case 'String':
return type(value) === typeExpr
default:
throw new TypeError('Argument "typeExpr" must be a function, string or Protocol object')
}
}
/**
* Create a protocol. A protocol is an object that performs a custom type check.
*
* @example
* // Create a protocol for testing a value as being a non-empty string.
* const Name = Protocol(value => typeof value === 'string' && value.trim())
*/
const Protocol = test => {
if (typeof test !== 'function') {
throw new TypeError('Argument "test" must be a function')
}
if (test.length !== 1) {
throw new TypeError('Argument "test" must accept only one formal argument')
}
return Object.freeze({
test,
get [Symbol.toStringTag] () {
return 'Protocol'
}
})
}
/**
* Wrap a type expression in a nullable protocol.
*
* @example islike(Nullable('Number'), null) // true
* @example islike(Nullable('Number'), undefined) // true
* @example islike(Nullable('Number'), 45) // true
* @example islike(Nullable('Number'), '45') // false
*/
const Nullable = typeExpr => {
return Protocol(value => {
return value === null ||
value === undefined ||
islike(typeExpr, value)
})
}
/**
* Create a protocol that describes properties on an object.
*
* @example islike(Record({ age: 'Number' }), { age: 45 }) // true
* @example islike(Record({ age: 'Number' }), {}) // false
*/
const Record = fields => {
if (Object(fields) !== fields) {
throw new TypeError('Argument "fields" must be an object')
}
const fieldArray = Object.freeze(
Object.keys(fields).map(fieldName => {
return Object.freeze({
name: fieldName,
type: fields[fieldName]
})
})
)
return Protocol(value => {
return value && fieldArray.every(field => {
return islike(field.type, value[field.name])
})
})
}
/**
* Create a protocol that describes an iterable collection.
*
* @example islike(Collection('Number'), [1, 2, 3]) // true
* @example islike(Collection('Number'), [1, '2', 3]) // false
* @example islike(Collection('Number'), new Set([1, '2', 3])) // true
* @example islike(Collection('Number', { collectionType: 'Array' }), [1, '2', 3]) // true
* @example islike(Collection('Number', { collectionType: 'Array' }), new Set([1, '2', 3])) // false
*/
const Collection = (itemType, { collectionType = null } = {}) => {
return Protocol(value => {
return value &&
typeof value[Symbol.iterator] === 'function' &&
(collectionType === null || type(value) === collectionType) &&
[...value].every(it => islike(itemType, it))
})
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment