Model Definition and Runtime Type Checking for JavaScript
var User = new Model({
name: String,
female: Boolean,
birth: Date
});
var joe = new User({ name: "Joe", female: false, birth: 1986 });
TypeError: expecting birth to be Date, got Number 1986
A recurring criticism of JavaScript is that it is a weakly typed language. The lack of strong typing is still responsible for numerous bugs in web applications. As an attempt to get rid of these issues, new typed languages that compile to JavaScript have been invented, like TypeScript from Microsoft. We also have static analysis tools like Flow from Facebook. These solutions bring static typing, which means that it only validate your code at build time, not runtime. Once compiled in JavaScript and run in the browser, there is no longer any guarantee that the variables you are working with have the right type.
From my own experience, static typing is insufficient to prevent most of bugs caused by type errors. This is because as part of JavaScript applications, we rely heavily on unreliable data sources: user inputs, web services, server-side rendering, browser built-ins, external dependencies, CDN... Static typing can not check the validity of this content since it is retrieved at runtime. For the same reason, developers usually face more type errors with this kind of data compared to their code variables that are under their control.
That's why Object Model is about strong dynamic type checking: it aims to get this type validation at runtime without requiring a new language or a compilation step. Whenever a property of your model is modified, the whole model is validated against its typed definition. This allows you to identify a problem much more quickly thanks to the generated exceptions. Object Model is also very easy to master: no new language to learn, no new tools, no compilation step, just a minimalist and intuitive API in a micro-library.
Validating at runtime also brings other benefits : you can define your own types, and use them in complex model definitions with custom assertions and more specific tests that can even change depending on your application state. Actually it goes much further than just type safety. Go on and see for yourself.
Many features, hopefully neither too much nor too few:
This library has been unit tested on latest versions of Chrome, Firefox and Edge, on Internet Explorer 11, Opera 20, and Safari 5.1. It should work on any browser implementing ECMAScript 5 specifications. If you find a fairly recent browser on which the tests do not pass, please post a Github issue and I will try to add support for it.
Support for IE < 11 had to be dropped in v2 because it required many hacks and was holding back other browsers. The latest version supporting IE9-10 is v1.1.5.
Note: You are currently on v1 documentation. The latest documentation is available here.
npm install objectmodel@1
Possible changes under consideration:
You can report bugs and contribute to the project on the GitHub repository.
Model
is the library namespace and the constructor function for Models. It takes as an argument a model definition, and returns a model constructor. Basic model constructor validate the argument passed against the model definition, and return the validated value.
var NumberModel = Model(Number);
// 'new' keyword is always optional
var x = NumberModel("42");
TypeError: expecting Number, got String "42"
Object models validate nested object properties against a definition tree. You may consider them as definitions of classes with advanced validation options. They provide automatic validation at initial and future assignments of the properties of the instance objects.
var Order = new Model({ // or Model.Object
product: {
name: String,
quantity: Number,
},
orderDate: Date
});
var myOrder = new Order({ product: { name: "Apple Pie", quantity: 1 }, orderDate: new Date() }); myOrder.product.quantity = 2; // no exceptions thrown myOrder.product.quantity = false; //try to assign a Boolean
TypeError: expecting product.quantity to be Number, got Boolean false
By default, model properties are mandatory. That means all properties defined are required on instance declaration, otherwise an exception will be raised. But you can specify a property to be optional by using the bracket notation, borrowed from the JSDoc specification:
var User = Model({
email: String, // mandatory
name: [String] // optional
});
var stan = User({ email: "stan@smith.com" }); // no exceptions var roger = User({ name: "Roger" }); // email is mandatory
TypeError: expecting email to be String, got undefined
Several valid types can be specified for one property, aka union types. So optional properties are actually union types between the original type and the values undefined
and null
. To declare an optional union type, add undefined
to the list.
var Animation = new Model({
delay: [Number, String], // can be a Number or a String
easing: [Boolean, String, undefined]
// optional property which can be a Boolean or a String
});
var opening = new Animation({ delay: 300 }); // easing is optional opening.delay = "fast"; // String is a valid type opening.delay = null;
TypeError: expecting delay to be Number or String, got null
opening.easing = true; // Boolean is a valid type opening.easing = 1;
TypeError: expecting easing to be Boolean or String or undefined, got Number 1
Instead of types, values can be specified for model properties. The property value must match the model one. If a regular expression is passed, the value must match it. Use brackets notation for value enumerations. Note that values and types can be mixed for one property.
var Shirt = new Model({
// the only acceptable value is "clothes"
category: "clothes",
// valid values: 38, 42, "S", "M", "L", "XL", "XXL"...
size: [Number, "M", /^X{0,2}[SL]$/],
// valid values: "black", "#FF0000", undefined...
color: ["black","white", new RegExp("^#([A-F0-9]{6})$"), undefined]
});
When you want to traverse nested objects, you always have to worry about the null pointer exception. Some languages such as Groovy have a safe navigation operator represented by ?.
to safely navigate through potential null references. In JavaScript, there is no such solution so you have to manually check for undefined/null
values at each level of the object. But within an Object Model, declared properties are null-safe for traversal: every instance complete its structure with undefined properties according to the model definition.
var Config = new Model({
local: {
time: {
format: ["12h","24h", undefined]
}
}
});
var config = { local: undefined }; // object duck typed
var model_config = Config(config); // object model
if(config.local.time.format === "12h"){ hour %= 12; }
TypeError: Cannot read property 'time' of undefined
// so to prevent this exception, we have to check this way: if(config != null && config.local != null && config.local.time != null && config.local.time.format === "12h"){ hour %= 12; } // with object models, no worries :) if(model_config.local.time.format === "12h"){ hour %= 12; } // model_config.local.time.format returns undefined
To specify default values for some properties of your model, put them in the model prototype. You can also use the defaults
method as a shorthand for setting all the default values at once. If these are not defined at object instanciation, their default value will be assigned.
var FileInfo = Model({
name: String,
size: [Number],
creationDate: [Date],
writable: Boolean
}).defaults({
name: "Untitled file",
size: 0,
writable: true
});
var file = new FileInfo({ writable: false });
file.name; // name is mandatory but a default value was passed
"Untitled file"
file.size; // size is optional, but the default value still apply
0
file.creationDate; // no default value was passed for this property
undefined
file.writable; // passed value overrides default value
false
Object.keys(file);
["name","size","creationDate","writable"]
Models declared can also be used for type checking, so you can compose structures of objects. Note that the sub-models do not necessarily need to be instantiated through the model constructor to be considered valid: only the definition of the sub-model must be respected. This is called duck typing, which can be summarized as "If it looks like a duck and quacks like a duck, then it's a duck". This naive approach is much more flexible and allows you, for example, to parse composed objects from JSON in one step.
var Person = Model({
name: String,
age: [Number]
});
var Lovers = Model({
man: Person,
woman: Person
});
var joe = { name: "Joe", age: 42 };
var ann = new Person({
name: joe.name + "'s wife",
age: joe.age - 5
});
var joe_and_ann = Lovers({
man: joe, // object duck typed
woman: ann // object model
});
Extensions create new models based on existing model definitions. You can declare new properties or modify previous ones. Therefore, it is an easy way to reproduce subtyping and class inheritance.
var Person = Model({
name: String,
female: Boolean
});
var Mother = Person.extend({
female: true,
child: Person
});
var joe = new Person({ name: "Joe", female: false }); var ann = new Person({ name: "Joe's wife", female: true }); var joanna = new Person({ name: "Joe's daughter", female: true }); ann.child = joanna; ann = Mother(ann); // cast ann to Mother model Mother.test(ann) && Person.test(ann)
true
joe = Mother(joe); // try to cast joe to Mother model
TypeError: expecting female to be true, got Boolean false expecting child to be { name: String, female: Boolean }, got undefined
But it goes further: you can do multiple inheritance and mix any number of parent models definitions and assertions. If some properties have the same name, those of the last object overrides the others.
var Client = Person.extend(User, Order, { store: String }); Client.prototype.sendConfirmationMail = function(){ return this.email + ": Dear " + this.name + ", thank you for ordering " + this.product.quantity + " " + this.product.name + " on " + this.store; }; Object.keys(Client.definition);
["name", "female", "email", "product", "orderDate", "store"]
var joe = new Client({ name: "Joe", female: false, email: "joe@email.net", product: { name: "diapers", quantity: 100 }, orderDate: new Date(), store: "daddy.net" }); joe.sendConfirmationMail();
joe@email.net: Dear Joe, thank you for ordering 100 diapers on daddy.net
You can add to your models any number of assertions that are custom test functions applied on the values of instances of the model. For example, we have this weird behaviour in JavaScript that makes NaN
and Infinity
considered as Number, which is probably not what you want. Just add Number.isFinite
as an assertion to get rid of them.
var Integer = Model(Number)
.assert(Number.isFinite)
.assert(Number.isInteger, "should be an integer");
function isPrime(n) {
for (var i=2, m=Math.sqrt(n); i <= m ; i++){
if(n%i === 0) return false;
}
return n > 1;
}
var PrimeNumber = Integer.extend().assert(isPrime);
// extend to not add isPrime assertion to the Integer model
Integer(Math.sqrt(1));
1
Integer(Math.sqrt(2));
TypeError: assertion failed: should be an integer
PrimeNumber(83);
83
PrimeNumber(87);
TypeError: assertion failed: isPrime
Some variable naming conventions are commonly used in JavaScript. For example, a leading underscore is used to specify a _private variable which should not be used outside its class methods. Also, constants are often in ALL_CAPS. Model definitions follow these conventions by making _underscored properties not enumerable and CAPITALIZED properties not writable.
var Circle = Model({
radius: Number, // public
_diameter: [Number], // private
UNIT: ["px","cm","mm"], // constant
_ID: [Number], // private and constant
});
var c = new Circle({ radius: 120, UNIT: "px", _ID: 1 }); c.radius = 100; c.UNIT = "cm";
TypeError: cannot redefine constant UNIT
Object.keys(c); // private variables are not enumerated
["radius", "UNIT"]
You can modify or remove these conventions by overriding Model.conventionForPrivate
and Model.conventionForConstant
functions.
Array models validate the array elements types. All the validation options for object models are also available for array model elements: type/value checking, optional properties, union types, enumerations, assertions...
var Family = Model({
father: Father,
mother: Mother,
children: Model.Array(Person), // array of Persons
grandparents: [Model.Array([Mother, Father])]
// optional array of Mothers or Fathers
});
var joefamily = new Family({ father: joe, mother: ann, children: [joanna, "dog"] });
TypeError: expecting Array[1] to be { name: String, female: Boolean }, got String "dog"
The validation is done on initial array elements passed to the model constructor, then on new elements added or modified afterwards.
Note that for browsers which do not support ES6 proxies, if you expand the array by storing an element at an index greater than the current array length (which is commonly considered as a bad practice), the new element and model assertions may not be validated. Use a mutator method like push
or splice
instead.
var Cards = Model.Array([Number, "J","Q","K"]);
var Hand = Cards.extend("Joker").assert(function(cards){
return cards.length === 2;
}, "should have two cards");
// Hand is an array of 2 Numbers, J, Q, K or Joker
var myHand = Hand( [7, "Joker"] ); myHand[2] = "K"; // may not trigger validation this way myHand.push("K"); // use this instead
TypeError: assertion failed: should have two cards
Function models provide validation on input (arguments) and output (return value). All the validation options for Object models are also available for Function models. The arguments passed to Model.Function
are the types of the arguments the function will receive. The defaults
method of Function models let you specify default values for missing arguments at call. Finally, the return
method is used to specify the type of the function return value.
var Calculator = Model.Function(Number, ["+","-","*","/"], Number)
.defaults(0, "+", 1)
.return(Number);
var calc = new Calculator(function(a, operator, b){ return eval(a + operator + b); }); calc(3, "+"); // use default value for 3rd argument
4
calc(6, "*", null);
TypeError: expecting arguments[2] to be Number, got null
To declare methods with classical JavaScript OOP programming, functions are declared in the constructor's prototype
. You can do the same with instances of function models. Another option is to declare function models in the model definition, then use the defaults
method of the object model to set the implementation in the prototype as well. See the 'Default values' section. The difference is that all the properties in the model definition are required for an object to be considered suitable for the model. In the following example, an object must have a function sayMyName
to be valid as a Person, while the function greet
is not mandatory.
var Person = Model({
name: String,
age: Number,
// function without arguments returning a String
sayMyName: Model.Function().return(String)
}).defaults({
sayMyName: function(){
return "my name is " + this.name;
}
});
// takes one argument of type Person, returns a String
var greetFnModel = Model.Function(Person).return(String);
Person.prototype.greet = greetFnModel(function(otherguy){
return "Hello "+ otherguy.name + ", " + this.sayMyName();
});
var joe = new Person({ name: "Joe", age: 28 }); var ann = new Person({ name: "Ann", age: 23 }); joe.sayMyName();
my name is Joe
joe.greet(ann);
Hello Ann, my name is Joe
joe.greet("dog");
TypeError: expecting arguments[0] to be { name: String, age: Number, sayMyName: Model.Function().return(String) }, got String "dog"
By default, validation errors are collected every time a model instance is created or modified, and thrown as TypeError
exceptions with a message describing all the errors found. It it possible to change this behaviour and add your own error collectors. For example, you may want to notify the user that an error occurred, or send the information to your server for error tracking on production.
You can change the error collector globally (Model.prototype.errorCollector
), individually by model (myModel.errorCollector
), or just once with validate(obj, myErrorCollector)
. Error collectors are functions called with an array of all the errors collected during the last model creation or modification. Every error is an object with these properties:
message
: a message describing all the errors caughtexpected
]: the expected type definition (when suitable)received
]: the received value, to compare to the expected (when suitable)path
]: the path where the error occurred in an object model definition (when suitable)var Student = Model({ name: String, course: [ "math","english","history" ], grade: Number }).assert(function(stu){ return stu.grade >= 60 }, "should at least get 60 to validate semester") Model.prototype.errorCollector = function(errors){ console.log("Global error collector caught these errors:"); errors.forEach(function(error){ console.dir(error); }); }; new Student({ name: "Joanna", course: "sleep", grade: 0 });
Global error collector caught these errors: { message: 'expecting course to be "math" or "english" or "history", got String "sleep"' path: "course" expected: ["math","english","history"] received: "sleep" } { message: "assertion failed: should at least get 60 to validate semester" }
Student.errorCollector = function(errors){ console.log("Student model error collector caught these errors:"); errors.forEach(function(error){ console.dir(error); }); }; new Student({ name: "Joanna", course: "math", grade: 50 });
Student model collector caught these errors: { message: "assertion failed: should at least get 60 to validate semester" }
Student.validate({ name: "Joanna", course: "cheating", grade: 90 }, function(errors){ console.log("This specific error collector caught these errors:"); errors.forEach(function(error){ console.dir(error); }); });
This specific error collector caught these errors: { message: 'expecting course to be "math" or "english" or "history", got String "cheating"' path: "course" expected: ["math","english","history","science"] received: "cheating" }
Model(definition)
Model.Object(definition)
Model.Array(itemDefinition)
Model.Function(definitionArgument1, definitionArgument2, ...)
function(variableName)
function(variableName)
model.definition
model.assertions
model.errorCollector = function(errors){ ... }
model.extend(...otherDefinitions)
model.assert(assertion, [description])
true
to validate the instance.model.validate(object, [errorCollector])
model.test(object)
true
if the object passed validates the model definition, without necessarily being instantiated through the model constructor (a.k.a. duck-typing).objectModel.defaults(defaultValuesObject)
functionModel.defaults(defaultValueArgument1, defaultValueArgument2, ...)
functionModel.return(returnValueDefinition)
Here are some models that you may find useful. These are not included in the library, so pick what you need or get them all from here
To get dynamic type validation, Object models have to override properties setters so that each assignment passes through a function that acts as a proxy. This has a cost, especially on old browsers. Therefore, it is not advisable to use object models in performance-critical parts of your applications. In particular, Array models and circular references in models have the most impact on performance. But in general, the loss of time does not exceed a few milliseconds and is quite negligible.
The easiest way is to declare this function as a property of your model constructor, then use it to instanciate your models instead of the model constructor itself:
var User = Model.Object({
firstName: String,
lastName: String,
fullName: String
});
User.create = function(properties){
properties.fullName = properties.firstName + " " + properties.lastName;
return new User(properties);
};
var joe = User.create({ firstName: "Joe", lastName: "Dalton" });
If this instance has been properly instantiated with the model constructor, you can retrieve a reference to this model through Object.getPrototypeOf(instance).constructor
. This is useful to get the property type of an instance attribute for example.
var User = Model({ name: String }),
joe = User({ name: "Joe" });
var modelOfJoe = Object.getPrototypeOf(joe).constructor;
// modelOfJoe === User
// modelOfJoe.definition.name === String
You can't refer to a model constructor or model instance that is not yet defined, so you have to update the definition afterwards:
var Honey = Model({
sweetie: undefined // Sweetie is not yet defined
});
var Sweetie = Model({
honey: Honey
});
Honey.definition.sweetie = [Sweetie];
var joe = Honey({ sweetie: undefined }); // ann is not yet defined
var ann = Sweetie({ honey: joe });
joe.sweetie = ann;
This feature is not built-in so that we can make room later for ES6 Maps, which are made for that job. But it is quite easy to get this kind of model using an assertion. Note that any new keys added to the map afterwards will not trigger validation, as they are not part of the model definition. You will have to call YourMapModel.validate(map)
manually.
Model.ObjectMap = function(valueType){ var valueModel = Model(valueType); return Model({}).assert(function checkValueTypes(map){ return Object.keys(map).every(function(key){ return valueModel.test(map[key]); }); }); } var REGEX_MMDDYYYY = /^(1[0-2]|0[1-9])/(3[01]|[12][0-9]|0[1-9])/[0-9]{4}$/; var BirthdayList = Model.ObjectMap([Date, REGEX_MMDDYYYY]); var friendsBirthday = BirthdayList({ "Joe": new Date(1985,6,3), "Jim": "02/22/1987", "Jack": null });
TypeError: assertion failed: checkValueTypes
Please check the documentation twice, then open an issue on the Github repository