Strong Dynamically Typed Object Modeling for JavaScript
A recurring criticism of JavaScript is that it is a weakly typed language. New languages that compile to JavaScript have been invented, such as TypeScript by Microsoft. We also have static analysis tools like Flow by Facebook. These solutions bring static typing, which means 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 intended types.
Static typing is insufficient to prevent most of the real and practical bugs caused by type errors. Indeed, JavaScript applications often involve 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 own code that is under their control and can benefit from type inference with their IDE.
This is why Object Model is about strong dynamic type checking: it aims to get strong validation constraints for your variables at runtime. Whenever a property is modified, the whole object is validated against its 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 plain old JS 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:
Note: You are currently on v2 documentation. The latest documentation is available here.
npm install objectmodel@2
Checkout the Github Releases
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. Basic models validate the argument passed against the model definition, and return the validated value.
var NumberModel = Model(Number);
// 'new' keyword is optional for models and model instances
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
If you can use ES6 on your project, it is very easy to define a model for your classes:
class Character extends Model({ lastName: String, firstName: String }){
get fullName(){ return `${this.firstName} ${this.lastName}`; }
}
var rick = new Character({ lastName: "Sanchez", firstName: "Rick" }); rick.lastName = 132;
TypeError: expecting lastName to be String, got Number 132
console.log(rick.fullName); // "Rick Sanchez"
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({
// can be a Number or a String
delay: [Number, String],
// optional property which can be a Boolean or a String
easing: [Boolean, String, undefined]
});
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
You can set a default value for any model with model.defaultTo(value)
. This default value will be used if the argument passed to the model constructor is undefined.
var N = Model(Number).defaultTo(1)
N(5) + N() === 6
To specify default values for some properties of your object models, 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 models. Note that the property values do not necessarily need to be model instances to be considered valid: only the definition of the associated 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".
When a model definition is recognized, the value is automatically replaced by an instance of the corresponding model. This naive approach is very time saving and allows you, for example, to parse composed models from JSON in one step. If there is somehow an ambiguity (such as two possible valid models within an union type), the value is kept unchanged and a warning console message will inform you how to solve this ambiguity.
var Person = Model({
name: String,
age: [Number]
});
var Lovers = Model({
husband: Person,
wife: Person
});
var joe = { name: "Joe", age: 42 };
var ann = new Person({
name: joe.name + "'s wife",
age: joe.age - 5
});
var couple = Lovers({
husband: joe, // object duck typed
wife: ann // object model
});
couple.husband instanceof Person === true // object has been casted to Person
Extensions create new models based on existing model definitions. You can declare new properties or override previous ones. Therefore, extending object models is an easy way to reproduce subtyping and class inheritance patterns.
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
Note that the extend
function can receive any number of additional models and/or definitions as arguments. They will be merged using this strategy:
Mother
model in the example above becomes Model({ name: String, female: true, child: Person })
Model.Array(Number).extend(Model.Array(String))
becomes Model.Array([Number, String]) ;
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 model instances. All assertions are called every time the model is changed, and must all return true
to validate. Exceptions thrown by assertions are catched and considered as assertion failures.
For example, we can get an Integer model by adding Number.isInteger
as an assertion to a basic Number
model.
var PositiveInteger = Model(Number)
.assert(Number.isInteger)
.assert(function(n){ return n >= 0 }, "should be greater or equal to zero")
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 = PositiveInteger.extend().assert(isPrime);
// extend to not add isPrime assertion to the Integer model
PositiveInteger(-1);
TypeError: assertion should be greater or equal to zero returned false for value -1
PositiveInteger(Math.sqrt(2));
TypeError: assertion isInteger returned false for value 1.4142135623730951
PrimeNumber(83);
83
PrimeNumber(87);
TypeError: assertion isPrime returned false for value 87
Assertions are inherited from Model.prototype
so you can add global assertions on all models. The second argument of the assert
method is an optional message shown when assertion fails. It can be a String or a function returning a String.
Model.Object.prototype.assert(function noUndeclaredProperties(obj) {
var modelDefinition = this.definition;
var extraKeys = Object.keys(obj).filter(function(key){
return modelDefinition.hasOwnProperty(key) === false;
});
if(extraKeys.length === 0) return true; // validate assertion
else return extraKeys; // pass information to the error message
}, function(extraKeys){
return "These properties are not defined in the model: " + extraKeys.join(', ');
})
var Order = Model({ price: Number }); var expectedOrder = Order({ price: 42.50 }); var receivedOrder = Order({ price: 42.50, taxes: 8.33, deliveryCost: 15.00 });
TypeError: These properties are not defined in the model: taxes, deliveryCost
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.
You can modify or remove these conventions by overriding Model.conventionForPrivate
and Model.conventionForConstant
functions.
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"]
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, 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 "should have two cards" returned false for value [7, "Joker", "K"]
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. 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
Under classical JavaScript OOP programming, methods 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 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
Person.prototype.greet = Model.Function(Person).return(String)(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: Function() => 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.
Error collectors are callback functions called with an array of all the errors collected during the last model inspection. Every error is an object with these properties:
message
: a message describing the errorexpected
]: the expected type definition or assertionreceived
]: the received value, to compare to the expectedpath
]: the path where the error occurred in an object model definitionThis is how you define an error collector globally for all models.
Model.prototype.errorCollector = function(errors){
console.log("Global error collector caught these errors:");
errors.forEach(function(error){ console.dir(error); });
};
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")
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 should at least get 60 to validate semester returned false for value { name: "Joanna", course: "sleep", grade: 0 }",
path: null,
expected: function(stu){ return stu.grade >= 60 },
received: { name: "Joanna", course: "sleep", grade: 0 }
}
This is how you define an error collector specifically by model
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 should at least get 60 to validate semester returned false for value { name: "Joanna", course: "math", grade: 50 }",
path: null,
expected: function(stu){ return stu.grade >= 60 },
received: { name: "Joanna", course: "math", grade: 50 }
}
And this is how you define an error collector to be used only once with validate(obj, myErrorCollector)
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.default
model.errorCollector = function(errors){ ... }
model.extend(...otherDefinitions)
model.assert(assertion, [description])
true
to validate the instance.model.defaultTo(defaultValue)
model.test(value)
true
if the value passed validates the model definition.
Works with duck typing.model.validate(instance, [errorCollector])
test
method, the object is not cast to its suitable
model (a.k.a duck typing) before being validated.
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
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.2.
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 recommended way is to use a factory function to instanciate your models. You can declare as many different factories as needed, which makes this pattern both simple and flexible.
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" });
With the constructor
property. If this property is used in your model, you can also retrieve it with Object.getPrototypeOf(instance).constructor
. This is useful for retrieving the type of a property for example.
var User = Model({ name: String }),
joe = User({ name: "Joe" });
var modelOfJoe = joe.constructor // or Object.getPrototypeOf(joe).constructor;
// modelOfJoe === User
// modelOfJoe.definition.name === String
You can't refer to a model or 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 checkValueTypes returned false for value { Joe: Wed Jul 03 1985 00:00:00, Jim: "02/22/1987", Jack: null }
Serializing in JSON necessarily implies that you lose the type information, except if you store it manually with your data, then retrieve it with a custom parsing function. It is for the best to let you decide how you want to store the type information within your data.
Here is a proposal of implementation using a simple { _value, _type }
wrapper:
Model.prototype.serialize = function(instance){
var availableModels = Object.keys(Model.serializables);
return JSON.stringify(instance, function(key, value){
var modelName = availableModels.find(function(name){
return value instanceof Model.serializables[name];
});
if(modelName && key !== "_value"){
return { _type: modelName, _value: value }
}
return value;
}, '\t');
}
Model.prototype.parse = function(json){
return JSON.parse(json, function(key, o){
if(o && o._type in Model.serializables){
return new Model.serializables[o._type](o._value);
}
return o;
})
}
var Type1 = Model({ content: String }).defaultTo({ content: 'Content 1' }),
Type2 = Model({ content: String }).defaultTo({ content: 'Content 2' }),
Container = Model({ items: Model.Array(Type1, Type2) });
// List all your serializable models here
Model.serializables = { Container, Type1, Type2 };
var a = new Container({ items: [new Type1, new Type2] });
var json = Container.serialize(a);
console.log(json);
var b = Container.parse(json);
console.log(
b instanceof Container,
b.items[0] instanceof Type1,
b.items[1] instanceof Type2
);
Please check the documentation twice, then open an issue on the Github repository