ObjectModel

Strong Dynamically Typed Object Modeling for JavaScript

What is this library ?

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.


What's inside the box ?

Many features, hopefully neither too much nor too few:

  • Typed structures: objects, arrays, maps, sets, functions...
  • Union types
  • Enumerations
  • Custom assertions
  • Optional properties
  • Default values
  • Null-safe object traversal
  • Easy composition or inheritance
  • Constants and private properties based on name conventions
  • Explicit error messages
  • Customizable error handlers
  • all in 9.89 KB minified, 3.79 KB gzipped, even less when using tree-shaking

Download

Current version: v3.2.0

Changelog and previous releases

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.

Support

Please report bugs on the GitHub repository.

You can also ask for support on the Gitter channel.


Model constructor

Model is the base class of all models and can be used as an alias for BasicModel and ObjectModel constructors.

Example
import { Model, BasicModel, ObjectModel } from "objectmodel"

Model(String)           // same as BasicModel(String)
Model({ name: String }) // same as ObjectModel({ name: String })

Basic models

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.

Model
const NumberModel = BasicModel(Number);
// 'new' keyword is optional for models and model instances
Instance
let x = NumberModel("42");
TypeError: expecting Number, got String "42"

Object models

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.

Model
const Order = new ObjectModel({
	product: {
		name: String,
		quantity: Number,
	},
	orderDate: Date
});
Instance
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

Usage with ES6 classes

If you are using ES6 classes in your project, it is very easy to define a model for your classes:

Model
class Character extends Model({ lastName: String, firstName: String }){
   get fullName(){ return `${this.firstName} ${this.lastName}`; }
}
Instance
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"

Optional properties

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

Model
const User = ObjectModel({
	email: String, // mandatory
	name: [String] // optional
});
Instance
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

Multiple types

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.

Model
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]
});
Instance
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

Value checking and enumerations

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.

Model
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]
});

Null-safe object traversal

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.

Model and instanciation
const Config = new ObjectModel({
	local: {
		time: {
			format: ["12h","24h", undefined]
		}
	}
});

const config = { local: undefined }; // object duck typed
const model_config = Config(config); // object model
Traversal
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

Default values assignment

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.

Model
let N = BasicModel(Number).defaultTo(1)
Instance
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.

Model
const FileInfo = ObjectModel({
	name: String,
	size: [Number],
	creationDate: [Date],
	writable: Boolean
}).defaults({
	name: "Untitled file",
	size: 0,
	writable: true
});
Instance
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"]

Composition with models as types

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.

Model
const Person = ObjectModel({
	name: String,
	age: [Number]
});

const Lovers = ObjectModel({
	husband: Person,
	wife: Person
});
Instance
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

Inheritance by extension

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.

Model
const Person = ObjectModel({
	name: String,
	female: Boolean
});

const Mother = Person.extend({
	female: true,
	child: Person
});
Instance
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

With ES6 classes

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.

Model
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 })
	}
}
Instance
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
ann.child.name
Joanna

Multiple inheritance

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.

Model
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"]
Instance
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

Sealed object models

By default, an object with properties not declared in the definition is still valid, as long as the rest of the definition is respected. This is an intended behaviour to enable model extensions and respect the Liskov substitution principle. Also, some frameworks and libraries add their own private properties to your data, and blocking them would prevent these tools from working properly.

However, if you really want to prevent all undeclared properties, you can set the model.sealed property to true. This is the equivalent of Object.seal() but for object models instances. The undeclared props will be catched on initial and future assignments

Model
const Package = ObjectModel({
	name: String,
	config: { verbose: [Boolean] }
}, { sealed: true });
// or Package.sealed = true
Instance
const Foo = new Package({ name: "foo", _id: 1 });
TypeError: property _id is not declared in the sealed model definition
const Bar = new Package({ name: "bar" });
Bar.config.hack = true;
TypeError: property config.hack is not declared in the sealed model definition
Package.sealed = false;
Bar.config.hack = true; // no more exceptions thrown

Assertions for custom validation tests

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.

Model
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
Instance
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

Private and constant properties

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.

Model
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 }
}
Instance
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

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.

Model
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");
Instance
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...

Model
const Family = ObjectModel({
	father: Father,
	mother: Mother,
	children: ArrayModel(Person), // array of Persons
	grandparents: [ArrayModel([Mother, Father])]
	            // optional array of Mothers or Fathers
});
Instance
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

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.

Model
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() }
)
Instance
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

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

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"

Custom error collectors

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 error
  • expected: the expected type definition or assertion/li>
  • received: the received value, to compare to the expected
  • path: the path where the error occurred in an object model definition

Global error collector

This 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 });
Result
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 }
}

Model error collector

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 });
Result
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 }
}

Single-use error collector

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) });
});
Result
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"
}

Custom devtool formatters

ObjectModel provides custom formatters for Models and Model instances in Chrome Developer Tools, available in Chrome and Opera. These formatters improve the way models and instances are displayed when logged in the console.

Enabling custom formatters

Chrome currently doesn’t have custom formatters enabled by default. You need to enter the DevTools settings via the menu at the top right of the DevTools panel, then select Preferences and check Enable custom formatters in the Console section.

The formatters for ObjectModel are included by default in the library bundle. If you are using modules, import them manually with :

import * from "objectmodel/src/devtool-formatter"

Full API

Imported from objectmodel root scope

Model Model(definition, params)
Constructor alias for basic and object models
BasicModel BasicModel(definition)
Constructor for basic models
ObjectModel ObjectModel(definition, params)
Constructor for object models
ArrayModel ArrayModel(itemDefinition)
Constructor for array models
FunctionModel FunctionModel(definitionArgument1, definitionArgument2, ...)
Constructor for function models
MapModel MapModel(keyDefinition, valueDefinition)
Constructor for map models
SetModel SetModel(itemDefinition)
Constructor for set models

Model methods and properties

definition model.definition
Returns the model definition
assertions model.assertions
Returns the list of model assertions
default model.default
Returns the default value if defined
errorCollector model.errorCollector = function(errors){ ... }
Function called when validation errors are detected
extend model.extend(...otherDefinitions)
Returns a new model based on the initial model definition merged with other definitions passed. Assertions are preserved too.
assert model.assert(assertion, [description])
Add a test function to the model that must return true to validate the instance.
defaultTo model.defaultTo(defaultValue)
Shorthand to set the default value of the model
test model.test(value)
Returns true if the value passed validates the model definition. Works with duck typing.
validate model.validate(instance, [errorCollector])
Manually triggers the validation of a model instance over its definition. It is automatically done whenever an instance property changes.
Note that contrary to the test method, the object is not cast to its suitable model (a.k.a duck typing) before being validated.
conventionForConstant function(variableName)
Internal function used to identify a constant property based on naming convention. You can override it to suit your needs.
conventionForPrivate function(variableName)
Internal function used to identify a non-enumerable property based on naming convention. You can override it to suit your needs.

Object models

defaults objectModel.defaults(defaultValuesObject)
Merge the object passed within the model prototype to set defaults values for some model properties.
sealed objectModel.sealed = true
If set to true, undeclared properties will be forbidden for this model. Can also be set on Model.prototype.
By default, additional properties are tolerated in order to allow model extensions.

Function models

return functionModel.return(returnValueDefinition)
Set the definition of the return value. Each call to the function must return a validated value, otherwise an exception will be raised.

Commonly used models

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


Common questions

Which browsers are supported ?

This library is unit tested against these browsers, depending of the version of Object Model:

v1.x
Chrome 29+, Firefox 24+, Edge, Internet Explorer 9+, Opera 20+, Safari 5.1+
v2.x
Support for IE < 11 had to be dropped in v2 because it required many hacks and was holding back other browsers.
v3.x
ObjectModel v3 is built around ES6 Proxies, so requires modern browsers : Edge 14+, Firefox 47+, Chrome 50+, Safari 10+. It also works on Node 6.0+.
What is the impact on performance ?

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.

How do I declare a constructor function to be called on instanciation before validating my model ?

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" });
How can I get the model from an instance ?

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
How should I deal with circular references in my model definitions ?

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;
How can I serialize/deserialize objects while preserving type information ?

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:

Implementation
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;
	})
}
Example
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
);

I have a question / suggestion / bug to report

Please check the documentation twice, then open an issue on the Github repository

Like what you see ? Share it with the world !