straits

Straits is an implementation of traits for JavaScript. It defines some conventions about traits and provides libraries to aid their usage, definition and implementation.

Usage no npm install needed!

<script type="module">
  import straits from 'https://cdn.skypack.dev/straits';
</script>

README

Straits

Straits is an implementation of traits for JavaScript. It defines some conventions about traits and provides libraries to aid their usage, definition and implementation.

Traits are a way to implement polymorphism: to extend objects and types with extra properties and behavior.

Quick example

Imagine that you want to define your own serialization function. It needs to work with primitive types (e.g. Number, String, Boolean, ...) and standard ones (Object, Array, Map...) as well as with custom types defined by you or other libraries. Each type might need to specialize the serialization function in a different way.

What's the correct way to do it? Our answer is traits!

Here the code, using the straits syntax:

// defining the trait set:
//const serializationTraitSet = { serialize: Symbol('serialize') };
// a preferred way to define a trait set::
import {TraitSet} from 'straits';
const serializationTraitSet = new TraitSet('serialize');

// telling the .* operator where to look for traits
use traits * from serializationTraitSet;

// implementing the `serialize` trait for standard types
Object.prototype.*serialize = function() { ... };
Array .prototype.*serialize = function() { ... };
Number.prototype.*serialize = function() { ... };

// implementing the `serialize` trait for custom types
MyCustomType.prototype.*serialize = function() { ... };
...

// using the `serialize` trait on an object
...
object.*serialize();

Traits offer many advantages above other possible approaches:

  • Traits make it trivial to specialize behavior in a truly polymorphic way.
  • Traits never break, disturb or collide with existing code. They're only relevant to the pieces of code where they're explicitly used.
  • Traits are fast. They exploit the JavaScript prototype chain without overhead.
  • When using the straits syntax, traits aren't bothered by variables in the scope, and avoid polluting the latter.

...Even the JavaScript standard, from ECMAScript 2015, is using them.

What are traits?

Traits are a way to implement polymorphism: to extend objects and types with extra properties and behavior. If you want to learn more about traits, give a read to the Wikipedia article.

Traits are implemented using symbol: a JavaScript primitive type, introduced in ECMAScript 2015 for purposes such as this one. It's a special type that can be used as key for object properties. Two different symbols are always different: symbol properties cannot collide. for(...in...) loops also ignore symbol properties, and if symbols are defined as non-enumerable (like the .* operator does), they're completely invisible (the only way to deal with such symbols is by having an instance of it, or by using Object.getOwnPropertySymbols()).

Another important building block to master traits (and JavaScript itself) is the prototype-chain. Each object in JavaScript has one prototype (which is a regular object as well). When an object's property is accessed, the property is looked for in the object itself; if it's not found it's searched in the object's prototype; if it's not yet found it's searched in the prototype's prototype, and so on recursively. symbols can be defined on an object and/or on their prototype, just like regular properties.

The ECMAScript 2015 standard defines some standard symbols and uses them as traits. Straits and the straits syntax is compatible with them. The standard uses names such as "protocols" to refer to them and an example is Symbol.iterator (see iteration protocols). When using the straits syntax, you can use traits * from Symbol; and then use [].*iterator and whatnot. Straits aims to introduce traits as a native feature of the language.

How to use traits?

Straits encourages defining traits in a trait set: an object whose keys are regular strings and their values are symbols. This is also what the global object Symbol does.

It's possible to use traits using three different styles:

The straits.utils module can help defining and using traits and trait sets in all of these fashions.

Straits syntax

The simplest and most performant way to use traits is by using a new syntax: the .* operator and use traits statement.

For more details, refer to the straits-babel documentation: a babel plugin implementing the straits syntax.

Assuming that myTraitSet provides a myTrait trait, the following piece of code...

use traits * from myTraitSet;
object.*myTrait

...would be roughly equivalent to:

object[ myTraitSet.myTrait ]

.* can also be used to assign traits, in which case the trait (i.e. symbol property) will be not-enumerable.

The .* operator only looks for the identifier to its right in the trait sets specified by the use traits statements. Variables available in the current scope won't interfere with the .* operator.

This syntax makes the code easier to write, read and understand. It avoids collisions between traits and scope variables and makes it easier and painless to import a large number of traits from a library.

The following is a real usage example:

import * as scontainers from 'scontainers';
use traits * from scontainers;

const array = [[1],7,[-2,5],2,7,[],4];

const result = array
    .*flatten()           // [1, 7, -2, 5, 2, 7, 4]
    .*reverse()           // [4, 7, 2, 5, -2, 7, 1]
    .*filter( item=>item%2 !== 0 )  // [7, 5, 7, 1]
    .*map( item=>item**2 )       // [49, 25, 49, 1]
    .*sum();
// result: 124

Traits as member symbols

It's possible to use traits directly, as they're just symbols. Code written this way will offer the same performance as the .* syntax.

Be careful when assigning traits this way: assigning the trait directly (i.e. obj[trait] = value;) will result in the trait being enumerable. One should use Object.defineProperty(obj, trait, {value:value, configurable:true}); instead; that's what happens when obj.*trait = value; is evaluated.

The latest example from above could look like this:

import * as scontainers from 'scontainers';

const array = [[1],7,[-2,5],2,7,[],4];

const result = array
    [scontainers.flatten]()           // [1, 7, -2, 5, 2, 7, 4]
    [scontainers.reverse]()           // [4, 7, 2, 5, -2, 7, 1]
    [scontainers.filter]( item=>item%2 !== 0 )  // [7, 5, 7, 1]
    [scontainers.map]( item=>item**2 )       // [49, 25, 49, 1]
    [scontainers.sum]();
// result: 124

Traits as free functions

It's possible to create free functions that wrap traits.

This could introduce a small overhead, as the free function is an indirection, but it has the advantage of working with null and undefined.

The straits.utils module offers functions to generate free functions from traits. Trait sets defined with starits-utils (i.e. with TraitsSet as prototype) already offer a freeFunction property to obtain a set of free functions from a trait set.

Here is once again the above example written using this approach:

import * as scontainers from 'scontainers';
const _ = scontainers.freeFunctions;
// equivalent to:
//import * as straits from 'straits';
//import * as scontainers from 'scontainers';
//use traits * from straits.utils;
//const _ = scontainers.*traitsToFreeFunctions();

const array = [[1],7,[-2,5],2,7,[],4];

// use `_` like you would use underscore or lodash:
const result =
    _.sum(
        _.map(
            _.filter(
                _.reverse(
                    _.flatten(array)  // [1, 7, -2, 5, 2, 7, 4]
                ),                    // [4, 7, 2, 5, -2, 7, 1]
                item=>item%2 !== 0
            ),                                  // [7, 5, 7, 1]
            item=>item**2
        )                                    // [49, 25, 49, 1]
    );
// result: 124

Full example: clone

Imagine that you want to define a clone function. clone would need to be polymorphic: it needs to be implemented in different ways for Objects, Arrays, Numbers etc; some custom types might also need a custom serialization method.

You should define a clone trait, implement it for existing types and let other developers know that if needed they can implement such trait for the types they define:

//const cloneTraits = {
//	clone: Symbol('clone')
//};
// or even better:
import * as straits from 'straits';
const cloneTraits = new straits.utils.TraitSet('clone');

use traits * from cloneTraits;

Object.prototype.*clone = (obj)=>Object.create({}, obj);
Array.prototype.*clone  = (arr)=>arr.slice()
Number.prototype.*clone = (num)=>num;
String.prototype.*clone = (num)=>str;
...


// implementing the `clone` behavior for `null` and `undefined`
// it will be used when the clone free function is called on something
cloneTraits.clone.*implDefault( function(subject) {
    if( subject === undefined || subject === null ) {
        return subject;
    }

    throw new Error(`${subject.toString()} cannot be cloned.`);
});

export default cloneTraits;

Note that a clone symbol is already defined in straits.common. That symbol should be used when implementing a similar semantics.

Straits vs ...

Traits are arguably the best way to implement polymorphism, especially when one wishes to implement a new polymorphic behavior on existing types.

Of course there are other ways to implement anything. Let's see some alternatives, taking the serialize example in consideration.

Classical properties

The simplest way to implement the serialize behavior could be by using a classical (string) property "serialize":

MyCustomType.prototype.serialize = function(){ ... };

The big downside of this approach is that you should never modify existing types or objects (with non-symbol properties). That's very bad practice and it could quickly lead to undefined behaviors.

The problem is that existing code relies on properties (features; attributes; characteristics) of existing objects.

  1. What happens if two different libraries need to define their own serialization function? If they both end up choosing the same name (e.g. serialize), they would override each other's implementation, At least one of the libraries would call the wrong function and this will surely cause problems.
  2. Existing code could iterate on the properties of existing objects, and the unexpected encounter of a new, non-standard property could cause undefined behavior.
  3. Imagine that Object.keys and Object.values were members of Object.prototype. How could you use those functions on the object { keys:[1,2,3], values:['a', 'b', 'c'] }? The member functions would be overridden by the object's own properties.

That's the reason why no decent library does this. Underscore and lodash, expose free functions. jQuery encapsulates their data in custom objects rather than using Array etc.

symbol properties can achieve exactly the same result while avoiding problems. They used to be more awkward to use, but the straits syntax is here to fix this issue.

Free functions

One could define serialize as a free function. The free function could enter different branches of code depending on the type of the object to serialize, but how could somebody specialize the behavior for a custom type introduced by a third library?

This is what libraries like Underscore and lodash, do, but the result is that they only support very few types (i.e. Object and Array). They don't even support Map and Set; let alone custom types.

It could be possible to think of a way to register the specialization of a function in some data structure, but this could easily degrade performance, introduce memory leaks, fail to work with mixins etc. WeakSet (introduced in ECMAScript 2015 along with symbol) would be very useful for this task, but the result would most likely be inferior to using traits.

FAQ

How does straits scoping work?

The .* operator looks for its right identifier in a different scope from the regular variable's scope. The scope it uses is populated by the use traits statements.

The traits scopes have a visibility similar to regular scopes: they are valid only for the current block and all its nested ones. Traits used in inner scopes don't override those defined in outer scopes though, as shown in the following example:

const traits1 = { x:Symbol() };
use traits * from traits1;

{
    const traits2 = { x:Symbol() };
    use traits * from traits2;

    // Error!
    // Symbol x offered by multiple trait sets.
    [].*x;
}

This is to avoid problems with API changes: imagine in fact that at the beginning of the development only traits1 defined the x trait. Code everywhere was written relying on that specific trait. If later during the development traits2 adds an x trait, the existing code should not start using that one, as the semantics may differ.

Ideally it should be possible to explicitly say in which trait set to look for a trait:

const traits1 = { x:Symbol() };
use traits x, * from traits1; // `x` is only looked for in `traits1`

{
    const traits2 = { x:Symbol() };
    use traits * from traits2;

    [].*x; // same as `[][traits1.x]`
}

But this hasn't been implemented yet.

Common errors

SyntaxError: Unexpected identifier; SyntaxError: Unexpected token *

The use traits statement and .* operator aren't standard JavaScript. They're a proposed extension. Currently, the only way to use them is transpiling them to valid JavaScript using straits-babel.

No trait set is providing symbol x.

One of the traits accessed with the .* operator is not provided by any trait set:

const traitSet = {};
use traits * from traitSet;

[].*x;

Symbol x offered by multiple trait sets.

One of the traits accessed with the .* operator is provided by 2 or more trait sets:

const traitSet1 = { x:Symbol() };
const traitSet2 = { x:Symbol() };
use traits * from traitSet1;
use traits * from traitSet2;

[].*x;

Status of straits

Straits is still in its alpha stage. It already works and it's proving itself very useful, but a few important features are still missing.

Symbol versioning

Currently, if two different versions of a library that uses straits are used in the same project, all the symbols exposed by such library are duplicated.

Let's consider a concrete example: scontainers. Let's say that the semantics of the flatten trait changes. The version of scontainers will be bumped, and new projects will start using the new version. Existing code might continue using the old one for a while. When both versions of scontainers are loaded in a project, two different full sets of traits will be created and coexist. The two versions of the same symbols (e.g. map, as well as flatten) will both be implemented for the standard types. The containers you defined, instead, will only implement the version of the traits your code is using. Some other modules used in the project (the ones that use the other version of scontainers) won't be able to use the container traits implemented on your objects.

The current behavior could be ok, but a different behavior might be preferable. The traits whose semantics didn't change should use the same symbol even among two different versions of scontainers. Only the traits whose semantics changed should use different symbols (and thus different implementations). This requires somehow versioning the symbols. It's something that a library (e.g. scontainers) could already do, but we believe that there should be a standard way and that straits.utils should offer an API to aid the effort.

Trait set extensibility

In most programming languages supporting traits (e.g. rust, haskell, go etc), one can automatically implement new traits for all the types that implement some other traits.

This cannot be easily done in JavaScript, as it's a dynamic language and virtually anything can implement traits at any point of the execution. Keeping a database of which objects are implementing which trait is not only very resource consuming, but would also result in memory leaks.

This feature would be very useful in JavaScript as well. Think of scontainers: a new trait whileEach could easily be implemented for every object that implements forEach. But how can we achieve that? As above, we need to choose a convention to extend traits, and straits.utils should offer aiding functions.

use trait t from traitSet

It might happen that two different trait sets expose traits with the same name. For instance straits.math.log is used to compute the logarithm function (i.e. Math.log), while straits.console.log is used to print values to the console (i.e. console.log). If your code wants to both use traits * from straits.math and use traits * from straits.console, .*log() can not currently be used.

An advanced version of the use traits statement could help us here. Imagine the following piece of code:

import * as straits from 'straits';
use traits * from straits.math;
use traits log, * from straits.console;
7.*log();

It means that all the traits are imported both from straits.math and from straits.console, but we are going to use the log symbol from straits.console.

straits-babel does not implement this syntax yet.

Who uses straits?

  • scontainers, a container library that offers functional-style traits for containers. It achieves great performances and a polished semantics.
  • esast, a library to manipulate JavaScript AST. Used by scontainers to generate efficient code on the fly.
  • straits.core, straits.console straits.math straits.reflect, modules exposing most of the free functions member of Object, console, Map, Reflect etc as traits.
  • straits.common, exposes a bunch of generic symbols that should be implemented by most types.
  • Symbol, the standard global object Symbol is compatible with straits.