In This Series: Stable and innovative code bases
- Stable and Innovative Code Bases
- How to Manage Code Across Many Independent Repositories
- Removing Side Effects - some juice isn't worth the squeeze
- Coping with Stateful Code
- How to Integrate Other Libraries using Symbols
CanJS, for better or worse, allows a near endless variety of design choices. If you like MVC, MVVM, centralized state management, etc, you can build your app that way.
Enabling this level of flexibility is difficult, especially because we don’t know what sorts of things people might want to integrate into CanJS.
We’ve come up with a pattern that uses ES6 symbols and an enhanced ES6 Reflect API that enables tight integration with 3rd party libraries (like Kefir streams) and new JavaScript types (like WeakMap) with minimal code and statefulness.
In this article, we will:
- Explore the problem of “unknown” types and why integration was difficult in CanJS 2.3.
- Understand a bit about Symbols and the Reflect API.
- See how symbols and a reflection API allow us to integrate unknown data types.
The problems with unknown types
To see why integration is a challenge, let’s look at some CanJS 2.3 code. The following template writes out whatever the message value is:
<h1>{{message}} world!</h1>
In 2.3, this template could have rendered with either a map, an object, or a compute:
can.Map | new can.Map({message: "Hallo"}) |
Object | {message: "Hello"} |
can.compute | {message: can.compute("Hola")} |
CanJS 2.3 had a read method that checked each of those 3 cases and read the appropriate value:
var read = function(obj, prop){
if(obj instanceof can.Map) {
return obj.attr(prop);
} else {
var value = obj[prop];
return value &&
value.isComputed ? value() : value;
}
}
This code had to know every possible type it might need to read. In CanJS 3.0, we greatly expanded the number of types we wanted to support:
Native JavaScript Types | ||
Object | object[prop] | {{object.prop}} |
Array | array.forEach(...) | {{#each array}} |
Map | map.get(key) | {{map[key]}} |
WeakMap | weakMap.get(key) | {{weakMap[key]}} |
Set | set.forEach(...) | {{#each set}} |
WeakSet | weakSet.has(key) | {{#if weakSet[key]}} |
Promise | promise.then( handler(value) ) | {{promise.value}} {{promise.reason}} {{promise.isPending}} {{promise.isResolved}} {{promise.isRejected}} |
CanJS core and ecosystem types |
||
can-define |
map.prop, map.get("prop") |
{{map.prop}} |
can-map |
map.attr("prop") |
{{map.prop}} |
can-simple-map |
map.get("prop") |
{{map.prop}} |
can-observation |
observation.get() |
{{observation}} |
can-compute |
compute() |
{{compute}} |
Kefir Stream |
stream.onValue( handler(value) ) |
{{stream.value}} {{stream.error}} |
While extending the read
function with all these types would be possible, it wouldn’t be maintainable. If someone wanted to integrate a new type, we’d need to update read
.
read
needs to operate on any value without being pre-programmed to do so. And beyond read
, there’s a huge variety of common data transformations we’d like to be able to do without being pre-programmed to handle the type. For example, we might want to be able to `Object.assign` an Object to a Map:
var map = new Map();
Object.assign( map, {name: "CanJS"} );
map.get("name") //-> CanJS
Or a Map to a WeakMap:
var key = {name: "CanJS"};
var map = new Map();
map.set(key, "3.0");
var weakMap = Object.assign(new WeakMap(), map )
weakMap.get(key) //-> "3.0"
We were able to solve these problems with Symbols and an enhanced Reflect API. Before we see how, a little background on Symbol and reflection.
Symbols
The solution to these problems is to use symbols (part of the ECMAScript 6 standard) to decorate our types.
To create a symbol, just call Symbol() like:
var isCool = Symbol();
Then, use that symbol as a property identifier:
var obj = {};
obj[isCool] = true;
obj[isCool] //-> true
Symbols are not enumerable by default:
Object.keys(obj) //-> []
Non-enumerability is important because we want to decorate objects without interfering with other code. Symbols allows us to decorate types with hidden functionality. The following example gives a plain object the ability to return the number of enumerable properties:
var sizeSymbol = Symbol();
var obj = {a: 1, b: 2};
obj[sizeSymbol] = function(){
return Object.keys(this).length;
}
obj[sizeSymbol]() //-> 2
Decorating objects in this way is more or less Symbol’s purpose. Symbols work well because:
- They don’t conflict with properties or other symbols.
- They aren’t enumerable by default.
- JavaScript already uses them for its operators.
In fact, many JavaScript types are already decorated with “well known” symbols. For example Symbol.iterator specifies the default iterator for an object. Assigning Symbol.iterator to an object lets that object be used with for..of loops.
The following makes a for(var num of obj)
loop log random numbers until a number greater than 0.9 is generated.
var obj = {}
obj[Symbol.iterator] = function() {
var done = false;
return {
next: function(){
if(done) {
return {done: true}
} else {
var num = Math.random();
if(num > 0.9) {
done = true;
}
return {done: false, value: num};
}
}
}
};
for(var num of obj) {
console.log(num);
}
// Logs 0.2592118112794619
// 0.5214201988831648
// 0.3123792504204661
// 0.9836294004422774
Reflection
JavaScript has operators and statements like for..of that use well-known symbols to inform how it should operate on unknown types.
JavaScript also added a Reflect API that enables operations on Objects and Functions. For example, you can call set a value on an object like:
var obj = {};
Reflect.set(obj, "prop","VALUE");
obj.prop //-> "VALUE"
In compiled languages such as Java, a reflection API lets you read and modify the state of the application at runtime. In a interpreted language like JavaScript, there’s often dedicated syntax for these APIs. After all, you can set a value on an object like:
var obj = {};
obj.prop = "VALUE"
obj.prop //-> "VALUE"
The Reflect object in JavaScript, it seems, was intended to clean up some of the rough corners of existing syntax or APIs. Read more about why you might use Reflect here. In the next section, we’ll explore CanJS’s enhanced Reflect API and how it enables CanJS to operate on unknown types.
can-symbol and can-reflect
To enable operations on unknown data-types, we created two projects:
- can-symbol - A symbol polyfill with additional “well known” symbols.
- can-reflect - A reflection API with an expanded API.
Like for..of, can-reflect
uses symbols on an object to know how to operate on that object. By default, it works with Object, Function, Set, and Map as follows:
var userToAge = new Map();
var user = {name: "Justin"};
userToAge.set(user, 34);
canReflect.getKeyValue(userToAge, user) //-> 34
can-reflect
can be used to loop, assign, and update these types too:
var key = {name: "CanJS"};
var map = new Map();
map.set(key, "3.0");
var newMap = canReflect.assign(new Map(), map )
newMap.get(key) //-> "3.0"
This works because we assign well-known symbols to Map like this:
var Symbol = require("can-symbol");
// Get the well-known symbol
var getOwnEnumerableKeysSymbol = Symbol.for("can.getOwnEnumerableKeys");
// Point the symbol to a function that returns the object's keys
Map.prototype[getOwnEnumerableKeysSymbol] = Map.prototype.keys;
Changing built-in types has historically been a poor design choice, but symbols make it ok because they don’t conflict with other values and are not enumerable.
can-reflect
has a helper that makes assigning symbols easy:
canReflect.assignSymbols(Map.prototype,{
"can.getOwnEnumerableKeys": Map.prototype.keys,
"can.setKeyValue": Map.prototype.set,
"can.getKeyValue": Map.prototype.get,
"can.deleteKeyValue": Map.prototype.delete,
"can.hasOwnKey": Map.prototype.has
});
So instead of the read function from earlier having to know about every possible type, it simply uses canReflect.getKeyValue
and expects the values passed to it to have symbols defining their behavior. Instead of:
var read = function(obj, prop){
if(obj instanceof can.Map) {
return obj.attr(prop);
} else {
var value = obj[prop];
return value &&
value.isComputed ? value() : value;
}
};
read
now looks like:
var read = function(obj, prop) {
return canReflect.get(obj, prop)
}
Starting with CanJS 3.9, CanJS uses can-reflect
to inspect and operate on any user provided type. This allows us to tightly integrate any type into CanJS. For example, it’s now possible to read Kefir streams’ values and errors directly in CanJS’ templates like:
{{stream.value}}
{{stream.error}}
To integrate another type into CanJS, create a package that imports that type and adds the right symbols to the type’s prototype. For example, the can-kefir plugin adds symbols to Kefir.Observable here. The can-reflect-promise plugin does the same for promises, allowing:
{{promise.value}}
{{promise.reason}}
{{promise.isPending}}
{{promise.isResolved}}
{{promise.isRejected}}
Etc
can-reflect and can-symbol have not only allowed us to integrate with other libraries, they have helped us:
- Improve CanJS’s performance - We were able to swap out a slower implementation of observables with a faster one.
- Simplify complex patterns - Check out can-key-tree (which is a tree implementation) and its event delegation example.
I hope to write more about this in a future article.
This pattern has been working well for us. I hope that more native JavaScript APIs begin to look for symbols to specify behavior. Do you have any interesting uses for Symbols and Reflect?