Composable typechecking in JavaScript
Motivation
If you are working in pure JavaScript on a project of any reasonable size, type errors will often become a major source of pain unless you have a process for spotting them early. This is because the error might be apparent immediately when some value of unexpected type is assigned to a variable, but (maybe much) later on when that variable is used.
And, unexpected things can happen because of implicit type conversion:
const bonus = "1" // Oops, this should be a number, maybe you forgot to use parseInt
const score = 100
// ... later on ...
const total = score + bonus
console.log( total ) // Prints out 1001, not the expected 101, and also total is a string
Type errors will often not affect the runtime for common calculations, so will propagate through the running program. In the example above, `total` ends up being a string and this will affect future calculations. You will become aware of the error only when something incorrect appears in the output of your program, or you happen to use one of these variables for something not allowed by the type.
Another common cause of issues is a variable being undefined or null when it is expected to have a value. This can show up in various ways, including seeing the string 'undefined' appearing in your output, and getting runtime errors when trying to access properties or methods on a value which is undefined.
Solution
A solution is to introduce a library for type checking, and use it to check the arguments of each function.
This does not have to be a full type system - just a surface level check eliminates many bugs, and this can be built up into a more complete type-checking system incrementally.
Lets see how it looks:
function addNumbers( a, b, c )
{
Assert.Number(a)
Assert.Number(b)
Assert.Number(c)
return a + b + c
}
And for arrays, we can check that an argument is an Array and also that it only contains a certain type of element:
function addNumbers( myArray )
{
Assert.Array(Assert.Number)( myArray )
return myArray.reduce( (a,b) => a+b, 0 )
}
Notice how the Assert.Array function call has two sets of brackets. Why is this?
Composability
Composability is a property of some systems (or in fact the individual pieces that make up the system) which is that those pieces fit together in a way that structures of any size can be built. An example is HTML: You have a set of tags (with certain limitations on where they can be placed) but mostly they can be nested in any order and to arbitrary depth.
This is usually a desirable property of systems because it makes them easier to use and is often an explicit design goal of that system. In fact, Composability can be most visible by its absence: You take it for granted that you can nest elements inside other elements in HTML to any depth using the same patterns at any depth, but if a layout system only allowed two levels of nesting or if each level had to be programmed differently it would be much more awkward to use and your code would end up unnecessarily complex.
How does this apply here?
Imagine if Assert.Array was written like this:
Assert.Array = function( elementValidationFunction, array )
{
if( ! Array.isArray(array) ) throw new Error("Expected an array")
array.forEach( array, element => elementValidationFunction(element) )
}
Then it could be used like this:
Assert.Array( Assert.Number, myArray )
Then you could check for an Array of Arrays like this:
Assert.Array( Assert.Array, myArray )
But you cannot go any deeper - what if you want to check for an Array of Arrays of Numbers (ie. a two-dimensional array)?
This is where the double brackets come in:
Assert.Array(Assert.Array(Assert.Number)) ( my2DArray )
`Assert.Array` actually returns a function which checks if all the array elements are a particular type, so it can be used anywhere a validation function is needed. The generic `Assert.Array` has been turned into a specific type.
This is equivalent to generics in type-safe languages, and this notation opens up many possibilities from type theory:
// Checks for either undefined or an array of numbers
Assert.Optional(Assert.Array(Assert.Number))( optionalArray )
// Checks for either an array of numbers or an array of strings
Assert.Either( Assert.Array(Assert.Number), Assert.Array(Assert.String) )( array )
// Checks for an array of optional number or string
Assert.Array( Assert.Optional( Assert.Either(Assert.Number, Assert.String) ) )( array )
Implementation
The way this works is that if an Assert function needs validation functions passed in, then it returns a new function which performs the validation:
Assert.Array = (elementValidationFunction) =>
{
return (valueToValidate) => {
if( ! Array.isArray(array) ) throw new Error("Expected an array")
array.forEach( array, element => elementValidationFunction(element) )
}
}
If called, you get a function which validates a specific type of Array:
// numberArrayValidation is a function
const numberArrayValidation = Assert.Array(Assert.Number)
const firstArray = [1,2,3]
const secondArray = ["4","5","6"]
numberArrayValidation( firstArray ) // Passes
numberArrayValidation( secondArray ) // Fails
But instead of storing the function in a variable, you can use a cleaner syntax by just calling the returned function straight away:
const firstArray = [1,2,3]
const secondArray = ["4","5","6"]
Assert.Array(Assert.Number)( firstArray ) // Passes
Assert.Array(Assert.Number)( secondArray ) // Fails
Support for objects
For Objects you need to verify that certain fields (and only those fields) are present and have the correct types.
It could look something like this example which checks if a player object has a name and a score:
Assert.Object({
name: Assert.String,
score: Assert.Number,
})( player )
This is cumbersome to write every time you need to check that a value is a player object, which might be in many places in the code. However, you can just bundle the checking into a function which checks the individual fields:
function AssertPlayerObject( player )
{
Assert.String( player.name )
Assert.Number( player.score )
}
AssertPlayerObject( player )
Source Code
Here is the source code:
/**
* Assert library
*/
const Assert =
{
// Ensures that the input is a number
Number: (value, optionalMinValue, optionalMaxValue) => {
if( typeof value !== 'number' ) Assert.Fail(`Expected number; got ${typeof value}: ${JSON.stringify(value)}`)
if( optionalMinValue !== undefined ) optionalMinValue = Assert.Number(optionalMinValue)
if( optionalMaxValue !== undefined ) optionalMaxValue = Assert.Number(optionalMaxValue)
if( optionalMinValue !== undefined && value < optionalMinValue ) Assert.Fail(`Expected number >= ${optionalMinValue}; got ${value}`)
if( optionalMaxValue !== undefined && value > optionalMaxValue ) Assert.Fail(`Expected number <= ${optionalMaxValue}; got ${value}`)
return value;
},
// Ensures that the input is a string
String: (string) => {
if( typeof value !== 'string' ) Assert.Fail(`Expected string; got ${typeof value}: ${JSON.stringify(value)}`)
return value;
},
// Generic array checker, returning a function to check an array using your elementValidation function
// That function ensures its input is an array of elements which all pass the elementValidation function
// It is used like this: Assert.Array(Assert.Number)( arrayOfNumbers )
// It is a validation function, so can be nested: Assert.Array(Assert.Array(Assert.Number))( arrayOfNumbers )
Array: (elementValidation) => (array) => {
array.forEach( element => elementValidation(element) )
},
// Generic optional value checker, returning a function to check an array using the elementValidation function
// Allows undefined, but if the value is defined it must pass validation
Optional: (validation) => (value) => {
if( value !== undefined ) validation(value)
},
// This is a separate function so that you can add a breakpoint in one place to catch errors, or easily update
// to your own custom error handling.
Fail: (message) => {
throw new Error(message)
}
}
The pattern is easy to expand upon, for instance checking maximum and minimum values for numbers, checking for integer valued numbers, checking whether some value is defined (ie. not undefined)