Strong Dynamically Typed Object Modeling for JavaScript
ObjectModel intends to bring strong dynamic type checking to your web applications. Contrary to static type-checking solutions like TypeScript or Flow, ObjectModel can also validate data at runtime: JSON from the server, form inputs, content from localStorage
, external libraries...
By leveraging ES6 Proxies, this library ensures that your variables always match the model definition and validation constraints you added to them. Thanks to the generated exceptions, it will help you spot potential bugs and save you time spent on debugging. ObjectModel 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 many other benefits: you can define your own types, use them in complex model definitions with custom assertions 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:
npm install objectmodel
Note: ObjectModel v3 only targets ES2015-compliant environments.
If you need to support older browsers, please use the 2.x version instead.
Take a look at Github Releases and Browsers support section for more information.
Please report bugs on the GitHub repository.
You can also ask for support on the Gitter channel.
Model
is the base class of all models and can be used as an alias for BasicModel
and ObjectModel
constructors.
import { Model, BasicModel, ObjectModel } from "objectmodel"
Model(String) // same as BasicModel(String)
Model({ name: String }) // same as ObjectModel({ name: String })
Basic models simply validate a variable against the model definition passed as argument, and return the validated value. BasicModel
constructor takes a model definition as the only argument. They are generally used to declare all the basic generic types that you will use in your application. You can find a list of common basic models here.
const NumberModel = BasicModel(Number);
// 'new' keyword is optional for models and model instances
let x = NumberModel("42");
TypeError: expecting Number, got String "42"
Object models validate nested object properties against a definition tree. They provide automatic validation at initial and future assignments of the properties of the instance objects.
const Order = new ObjectModel({
product: {
name: String,
quantity: Number,
},
orderDate: Date
});
const 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 are using ES6 classes in 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}`; }
}
const 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
const User = ObjectModel({
email: String, // mandatory
name: [String] // optional
});
const stan = User({ email: "stan@smith.com" }); // no exceptions const 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.
const Animation = new ObjectModel({
// can be a Number or a String
delay: [Number, String],
// optional property which can be a Boolean or a String
easing: [Boolean, String, undefined]
});
const 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
In model definitions, you can also specify values instead of types for model properties. The property value will have to match the model one. Just like union types, use brackets notation for value enumerations.
If a regular expression is passed, the value must match it.
const Shirt = new ObjectModel({
// 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.
const Config = new ObjectModel({
local: {
time: {
format: ["12h","24h", undefined]
}
}
});
const config = { local: undefined }; // object duck typed
const 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.
let N = BasicModel(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.
const FileInfo = ObjectModel({
name: String,
size: [Number],
creationDate: [Date],
writable: Boolean
}).defaults({
name: "Untitled file",
size: 0,
writable: true
});
let 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 applies
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.
const Person = ObjectModel({
name: String,
age: [Number]
});
const Lovers = ObjectModel({
husband: Person,
wife: Person
});
const joe = { name: "Joe", age: 42 };
const ann = new Person({
name: joe.name + "'s wife",
age: joe.age - 5
});
const 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, it is an easy way to reproduce subtyping and class inheritance.
const Person = ObjectModel({
name: String,
female: Boolean
});
const Mother = Person.extend({
female: true,
child: Person
});
let joe = new Person({ name: "Joe", female: false }); let ann = new Person({ name: "Ann", female: true }); let joanna = new Person({ name: "Joanna", female: true }); ann = new Mother({ name: "Ann", female: true, child: joanna }) ann instanceof Mother && ann instanceof Person // 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
Extended models inherit the parent's prototype chain, so you can easily combine it with class inheritance. Just make sure to respect the Liskov substitution principle when you extend a type definition.
class Person extends ObjectModel({ name: String, female: Boolean }){
constructor({ name, female }){
if(!female) name = `Mr ${name}`
super({ name, female })
}
}
class Mother extends Person.extend({ female: true, child: Person }){
constructor({ name, female, child }){
super({ name: `Mrs ${name}`, female, child })
}
}
let joe = new Person({ name: "Joe", female: false }) let joanna = new Person({ name: "Joanna", female: true }) let ann = new Mother({ name: "Ann", female: true, child: joanna })
joe.name
Mr Joe
ann.name
Mrs Ann
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.
const 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"]
const 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 in 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.
Assertions are inherited from the model prototype, so you can add global assertions on all models by setting them in Model.prototype
. 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.
const PositiveInteger = BasicModel(Number)
.assert(Number.isInteger)
.assert(n => n >= 0, "should be greater or equal to zero")
function isPrime(n) {
for (let i=2, m=Math.sqrt(n); i <= m ; i++){
if(n%i === 0) return false;
}
return n > 1;
}
const 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
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 not usable outside of the instance's own methods, and CAPITALIZED properties not writable.
You can modify or remove these conventions by overriding the conventionForPrivate
and conventionForConstant
methods in your model or globally in Model.prototype
.
class Circle extends ObjectModel({
radius: Number, // public
_diameter: Number, // private
UNIT: ["px","cm"], // constant
_ID: [Number], // private and constant
}){
get _diameter(){ return this.radius * 2 }
getDiameter(){ return this._diameter }
}
let c = new Circle({ radius: 120, UNIT: "px", _ID: 1 }); c.radius = 100; c.UNIT = "cm";
TypeError: cannot redefine constant UNIT
console.log( c._diameter )
TypeError: cannot access to private property _diameter
console.log( c.getDiameter() )
200
Object.keys(c); // private variables are not enumerated
["radius", "UNIT"]
Array models validate the type of all elements in an array.
The validation is done on initial array elements passed to the model, then on new elements added or modified afterwards.
import { ArrayModel } from "objectmodel";
const Cards = new ArrayModel([Number, "J","Q","K"]);
// Hand is an array of 2 Numbers, J, Q, or K
const Hand = Cards.assert(a => a.length === 2, "should have two cards");
const myHand = Hand( [7, "K"] ); myHand[0] = "Joker"
TypeError: expecting Array[0] to be Number or "J" or "Q" or "K", got String "Joker"
myHand.push("K");
TypeError: assertion "should have two cards" returned false for value [7, "Joker", "K"]
All the validation options for previous models are also available for array model elements: type/value checking, optional properties, union types, enumerations, assertions...
const Family = ObjectModel({
father: Father,
mother: Mother,
children: ArrayModel(Person), // array of Persons
grandparents: [ArrayModel([Mother, Father])]
// optional array of Mothers or Fathers
});
const joefamily = new Family({ father: joe, mother: ann, children: [joanna, "dog"] });
TypeError: expecting Array[1] to be { name: String, female: Boolean }, got String "dog"
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 FunctionModel
are the types of the arguments the function will receive, and the return
method is used to specify the type of the function return value.
import { FunctionModel, BasicModel } from "objectmodel";
const Operand = BasicModel(Number).assert(Number.isFinite);
const Operator = BasicModel(["+","-","*","/"])
const Calculator = FunctionModel(Operand, Operator, Operand).return(Operand);
const calc = new Calculator((a, operator, b) => eval(a + operator + b));
calc(3, "+", 1);
4
calc(6, "*", null);
TypeError: expecting arguments[2] to be Number, got null
calc(1, "/", 0);
TypeError: assertion "isFinite" returned false for value Infinity
In 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 provide a default implementation in the model definition by using the defaults
method. 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.
const Person = ObjectModel({
name: String,
// function without arguments returning a String
sayMyName: FunctionModel().return(String)
}).defaults({
sayMyName: function(){ return "my name is " + this.name }
})
// takes one Person as argument, returns a String
Person.prototype.greet = FunctionModel(Person).return(String)(
function(otherguy){ return "Hello "+ otherguy.name + ", " + this.sayMyName() }
)
const joe = new Person({ name: "Joe" }); joe.sayMyName();
my name is Joe
joe.greet({ name: "Ann", greet: "hi ?" });
Hello Ann, my name is Joe
joe.greet({ name: "dog", sayMyName: "woof !" });
TypeError: expecting arguments[0].sayMyName to be "Function", got String "woof !"
Map models validate ES6 Map
objects by checking both keys and values. The arguments passed to MapModel
are respectively the definition for the keys and the definition for the values.
import { MapModel, Model } from "objectmodel";
const Course = Model([ "math", "english", "history" ])
const Grade = Model([ "A", "B", "C" ])
const Gradebook = MapModel(Course, Grade)
const joannaGrades = new Gradebook([ ["math", "B"], ["english", "C"] ]) joannaGrades.set("videogames", "A")
TypeError: expecting Map key to be "math" or "english" or "history", got String "videogames"
joannaGrades.set("history", "nope")
TypeError: expecting Map["history"] to be "A" or "B" or "C" , got String "nope"
Set models validate ES6 Set
objects by checking the type of all the elements in the set. The API is the same as array models.
import { SetModel } from "objectmodel";
const FavoriteCourses = SetModel(Course)
const joannaFavorites = FavoriteCourses([ "math", "english" ]) joannaGrades.add("sleeping")
TypeError: expecting Set value to be "math" or "english" or "history", got String "sleeping"
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 assertion/li>
received
: 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(error => { console.dir(error) });
};
const Student = ObjectModel({
name: String,
course: [ "math","english","history" ],
grade: Number
}).assert(student => student.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: student => student.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(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: student => student.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(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"
}
BasicModel(definition)
ObjectModel(definition)
ArrayModel(itemDefinition)
FunctionModel(definitionArgument1,
definitionArgument2, ...)
MapModel(keyDefinition, valueDefinition)
SetModel(itemDefinition)
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.
function(variableName)
function(variableName)
objectModel.defaults(defaultValuesObject)
objectModel.sealed = true
true
, undeclared properties will be forbidden for this model. Can also be set on Model.prototype
.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 is unit tested against these browsers, depending of the version of Object Model:
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.
const User = ObjectModel({
firstName: String,
lastName: String,
fullName: String
});
User.create = function(properties){
properties.fullName = properties.firstName + " " + properties.lastName;
return new User(properties);
};
const 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.
const User = ObjectModel({ name: String }),
joe = User({ name: "Joe" });
const 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:
const Honey = ObjectModel({
sweetie: undefined // Sweetie is not yet defined
});
const Sweetie = ObjectModel({
honey: Honey
});
Honey.definition.sweetie = [Sweetie];
const joe = Honey({ sweetie: undefined }); // ann is not yet defined
const ann = Sweetie({ honey: joe });
joe.sweetie = ann;
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, models){
const names = Object.keys(models);
return JSON.stringify(instance, function(key, value){
const modelName = names.find(name => value instanceof models[name]);
if(modelName && key !== "_value"){
return { _type: modelName, _value: value }
}
return value;
}, '\t');
}
Model.prototype.parse = function(json, models){
return JSON.parse(json, function(key, o){
if(o && o._type in models){
return new models[o._type](o._value);
}
return o;
})
}
const Type1 = ObjectModel({ content: String }).defaultTo({ content: 'Content 1' }),
Type2 = ObjectModel({ content: String }).defaultTo({ content: 'Content 2' }),
Container = ObjectModel({ items: ArrayModel([Type1, Type2]) });
// List all your serializable models here
const serializableModels = { Container, Type1, Type2 };
let a = new Container({ items: [new Type1, new Type2] });
let json = Container.serialize(a, serializableModels);
console.log(json);
let b = Container.parse(json, serializableModels);
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