JavaScript traits
Traits are a feature used by most modern languages to implement polymorphism without relying on inheritance.
We'll introduce them, show which problems they would solve in JavaScript, and suggest a way to implement them. Hope you find it interesting!
A few words about JavaScript and modern languages
JavaScript has been my language of choice for the past 5 years in spite of its infamous quirks.
It's fast to write, efficient to run, but above all else, it is modern. The language itself as well as its ecosystem uses great features and solutions, the best ones we know of.
JavaScript supports Object Oriented Programming and uses duck typing to achieve polymorphism. This approach is more powerful and flexible than inheritance, but it still has some major flaws.
A better, more modern way to implement OOP is using traits.
Implementing decent traits in JavaScript through duck typing and prototypal inheritance used to have problems, but since a recent version of the language (ECMAScript 6) added a new primitive data type (symbol
), the situation has improved.
Very few are using this exciting feature of the language though, and that's what motivated this article.
What are traits?
Traits are a way to add semantics to existing types without risking unintentional interference with existing code.
Traits are an alternative to inheritance as a method for implementing polymorphism, and all the modern languages have them: think of go, haskell and rust just to name a few.
JavaScript is a prototypal, duck typed language, and this has always allowed programmers to do something very similar just by adding a new property to an existing prototype. But this simple addition has major problem and should be avoided. In fact you should never modify the prototype of types you don't own, and that's why most libraries go out of their way to offer new functionalities to existing types without actually modifying those types.
Recent versions of JavaScript (i.e. ECMAScript 6) added a new primitive data type, symbol
, which can be used to implement traits effectively[1]. A symbol
is basically a unique identifier that can be used as a property and that will never collide with anything else.
In ECMAScript 6 they needed a way to expand standard types without breaking compatibility with existing code. That is why they introduced symbol
, and they're indeed using it to implement traits. However, instead of advertising this new feature and making traits a first class citizen, they have let symbol
s remain in the shadows.
The standard calls this feature protocol, instead of trait, and one of several examples is the iteration protocol.
Besides the lack of guidelines and good examples, another issue makes it tough to use symbol
s as traits: a lack of good syntax to do so.
But enough talking! Let's look at an example.
Why do we need traits? An example
Imagine that you need a serializer to convert pieces of data into a string that can be stored somewhere.
JSON.stringify()
is not good enough, as it doesn't support "complex" objects (try to stringify a circular object, a RegExp
, a Map
etc, and you'll see).
Well, let's write our own serialization function then.
We want to support primitive data types (boolean
, number
, string
etc), built-in types (Array
, Map
, RegExp
etc), as well as the classes we or somebody else define.
Something might be impossible to serialize, like Function
s or Promise
s, and that's OK: we'll just throw an error if we're given to serialize one of these.
What we are trying to do, is to add a new serializing logic to most types, regardless of wether they're defined by us, by other people, or built-in in the language. This logic needs to be custom-written for each type.
We can achieve that by adding a new serialize
method to everything. This way var.serialize()
will return a serializable representation for any variable var
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | // for some primitive data types `JSON.stringify()` works just fine: Boolean.prototype.serialize = Number.prototype.serialize = String.prototype.serialize = function() { return JSON.stringify(this); }; // implementing `serialize` for some bult-in types RegExp.prototype.serialize = function() { return `/${this.source}/${this.flags}`; }; Array.prototype.serialize = function() { const values = this.map( (item)=>item.serialize() ).join(`, `); return `[${values}]`; }; Object.prototype.serialize = function() { const properties = Object.entries( this ).map( ([key, value])=> `${key.serialize()}: ${value.serialize()}` ); return `{${properties}}`; }; // implementing it for our custom types as well... class Person { constructor(name, age) { this.name = name; this.age = age; this.objects = []; } // ... serialize() { return `Person(${this.name.serialize()}, ${this.age.serialize()}){` + `objects:${this.objects.serialize()}` + `}`; } } |
Let's try it out...
1 2 3 | const peoro = new Person("peoro", 32); peoro.objects.push({ a:true, re:/^...$/g }); console.log( peoro.serialize() ); |
It prints:
1 | Person("peoro", 32){objects:[{"a": true,"re": /^...$/g}]} |
Which is exactly what we wanted. Amazing! Isn't it? Well, not really.
We modified existing types we have no ownership of. That's called monkey patching. It seems to work, but will give many serious problems as soon as somebody tries to use our serializer in a real application. Let's see a few:
- Try to serialize the object
{serialize:true}
: you'll get an error, since theserialize
property of such object overridesObject.prototype.serialize
. - Somebody else might define a different
serialize
method to serialize objects in a different format. Our serializer and theirs will be incompatible; if both are loaded in the same project (even as an indirect dependency) things will break in unexpected ways. - Try to iterate using
for...in
on a plain object: you'll iterate over theObject.prototype
'sserialize
property as well. This is gonna break the majority of existing code.
The problem with monkey patching is that we're modifying global data: any function that wasn't written by us might rely on assumptions on existing objects that we might have broken,
This is the reason why libraries (including the huge ones that could impose their own standards - think of jQuery or lodash) won't modify built-in types. They would rather expose free functions (like lodash), or wrappers to encapsulate existing objects and add new methods only to their wrappers (like jQuery).
It's important to note that the solutions chosen by these libraries are quite limited: it's hard to specialize the behavior for wrappers and free functions. When you write them, you might hardcode a waterfall of if
s to support a bunch of types, but later it cannot be extended. You won't be able to make their functions work with your custom types.
As an exercise, try to define a serialize
function able to serialize several types without modifying existing objects nor their prototype. Then try to make it possible for the users of your library to add support for their own types, or to existing third-party objects.
Most of the solutions you might consider (e.g. using a Type → serializationFunction
map) would likely result in further unexpected problems.
This is where symbol
s come in our assistance.
Welcome to the world of traits.
How to implement traits in JavaScript
A symbol
is a primitive type introduced in ES6 which can be used as an object's key, and it's guaranteed to never ever clash with anything else: if sym
is a symbol
, the only way to access object[sym]
is by using sym
itself. Besides, for...in
loops won't iterate over symbol
s.
It's not a coincidence that symbol
s work this way: they were added to the standard for the exact same reason why we need them.
ECMAScript 6 wanted to add new functionalities to existing types, but, as we've seen, it was impossible to do so without risking to break existing code.
For an example of how the standard is using them, look at the iterable protocol. A new symbol Symbol.iterator
was introduced. The types that implement it can be iterated over using the for...of
syntax. Such symbol is implemented for Array
, TypedArray
, String
, Map
, Set
, and you can implement it on your own types as well.
Our serializer should instantiate a serialize
symbol, use it, and expose it for everybody to use:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | const serialize = Symbol(); Boolean.prototype[serialize] = Number.prototype[serialize] = String.prototype[serialize] = function() { /*...*/ }; RegExp.prototype[serialize] = function() { /*...*/ }; Array.prototype[serialize] = function() { /*...*/ }; Object.prototype[serialize] = function() { /*...*/ }; class Person { // ... [serialize]() { // ... } } module.exports = { Person, serialize, }; |
Our users can then implement the same serialize
symbol on their types and use it.
No other existing piece of code will be affected.
symbol
s aren't the final answer though...
They're very powerful and are used by the standard to implement traits, but the amount of documentation covering them is miniscule. Virtually no guidelines, very few tutorials or articles explaining what they are and how to use them.
The result is that very few modules are using them.
A further problem concerns their syntax: there's no special syntax to use them, and in some cases this becomes a pain.
Imagine a lodash-traits library that offers and implement a symbol
for each lodash function.
You wouldn't be able to just do:
1 2 3 4 5 6 | require('lodash-traits'); // returning an object's values (numbers), sorted and without duplicates return object // { a:7, b:12, c:4, d:7, e:1 } .values() // [7, 12, 4, 7, 1] .sortBy() // [1, 4, 7, 7, 12] .uniq() // [1, 4, 7, 12] |
You'd have to do...
1 2 3 4 5 6 | const _ = require('lodash-traits'); // returning an object's values (numbers), sorted and without duplicates return object // { a:7, b:12, c:4, d:7, e:1 } [_.values]() // [7, 12, 4, 7, 1] [_.sortBy]() // [1, 4, 7, 7, 12] [_.uniq]() // [1, 4, 7, 12] |
And this becomes uncomfortable pretty fast.
This is why, in addition to traits, we're proposing a new syntax, designed to aid trait development.
A better syntax for traits
We're proposing a language extension, the straits syntax, to be able to write the previous snippet the following way:
1 2 3 4 5 6 | use traits * from require('lodash-traits'); // returning an object's values (numbers), sorted and without duplicates return object // { a:7, b:12, c:4, d:7, e:1 } .*values() // [7, 12, 4, 7, 1] .*sortBy() // [1, 4, 7, 7, 12] .*uniq() // [1, 4, 7, 12] |
What does it mean?
use traits * from traitSet;
means that we will be looking for symbols inside the object traitSet
. We call traitSet
a trait set.
object.*key
means that we're accessing object
with the symbol called key
found in the trait set in use.
In pratice:
1 2 | use traits * from traitSet; object.*key |
Is roughly equivalent to:
1 | object[ traitSet.key ] |
We would write the serialization part of our module the following way:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | const traits = { serialize: Symbol() }; // enabling .* for our traits use traits * from traits; // implementing .*serialize() for `Number` Number.prototype.*serialize = function() { return this.toString(); }; // ... // using .*serialize() const value = 7; value.*serialize(); // "7" |
This syntax is compatible with the standard symbol
s built-in in ECMAScript 6:
1 2 3 4 | use traits * from Symbol; // this is `[][Symbol.iterator]` [].*iterator |
It's possible to use traits from multiple trait sets at the same time, and we'll receive an error in case a trait is duplicated or missing.
This syntax is meant to...
- Turn
symbol
s into first class citizens of JavaScript. - Make traits easier both to declare and use.
- Avoid conflicts and mistakes between variables in scope and traits.
It's currently possible to develop code using this syntax, and to convert it into standard JavaScript using a babel plugin: straits-babel
.
Article conclusion and straits introduction
Hopefully it's now clear why there's a need for traits, how to create them using symbol
s, prototypal inheritance and duck typing, and how to use them comfortably.
The straits project offers a number of common functions to aid in the declaration and usage of symbol
s, traits and trait sets.
If you want to give it a chance, just run npm init @straits
in an empty directory: it will set up a project ready to use the new syntax. Then run npm install
and everything will be ready: npm start
will run src/index.js
, a hello-world ready to be played with.
If you like traits, you to just use the straits syntax in your project.
It will be completely transparent to your users, since the code you'll release or publish on npm will be transpiled: standard, regular JavaScript. The users of your module are free to choose whether they want to use use this syntax as well, or rather use symbol
s manually or even through free-functions.
Give a look at lodash-traits' test/index.js
to see how a module using traits can be used with or without using the straits syntax.
If you want to give a look at some projects relying on traits, check out:
- lodash-traits: a trait set wrapping lodash functions.
- chalk-traits: a trait set wrapping chalk functions.
- Scontainers: a powerful, high performance library (although still in alpha) to work with collections of data.
- ESAST: a library to manipulate JavaScript AST in a comfortable way (i.e. without wrapper objects).
1: Alternatively, WeakMap
could be used to implement traits. WeakMap
was introduced in ECMAScript 6 as well, and we decided against it since the standard uses symbol
s to implement traits.