Skip to content

Tuples and value objects for JavaScript 🀷

License

Notifications You must be signed in to change notification settings

slikts/tuplerone

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

tuplerone

View this project on npm Travis Coveralls Dev Dependencies semantic-release Dependabot

A lightweight, efficient tuple and value object implementation for JavaScript and TypeScript.


A quick reminder about what tuples are (using Python):

(1, 2, 3) == (1, 2, 3) # β†’ True

A JavaScript version of something similar looks like this:

'[1,2,3]' === '[1,2,3]'; // β†’ true

Except it's using a string and would need to be unserialized with JSON.parse() to allow accessing the separate members. Moreover, JSON is limited in what values can be serialized.

You could alternatively use "1,2,3" and String.split(","), but it's also not very convenient. Just using an array doesn't work:

[1, 2, 3] === [1, 2, 3]; // β†’ false

Each JavaScript array is a different object and so its value is the reference to that object. Tuples are a way to make that reference the same if the array members are the same. Using Tuplerone:

Tuple(1, 2, 3) === Tuple(1, 2, 3);

Example use case for tuples is dealing with memoization like React's memo() or PureComponent, since you can pass lists as props to components without forcing re-renders or manually caching the list. It's also useful for using multiple values as keys with Map(). In general, it's just a nice thing to have in your toolbox.

Try Tuplerone in a sandbox


This library is:

  • tiny (bundle size is under one kilobyte compressed), with no dependencies
  • well-typed using TypeScript (but can still be used from JavaScript, of course)
  • well-tested with full coverage
  • efficient using an ES2015 WeakMap-based directed acyclic graph for lookups

The Tuple objects are:

  • immutable – properties cannot be added, removed or changed, and it's enforced with Object.freeze()
  • array-like – tuple members can be accessed by indexing, and there's a length property, but no Array prototype methods
  • iterable – tuple members can be iterated over, for example, using for-of loops or spread syntax

There exists a stage-1 proposal for adding a tuple type to JavaScript and a different stage-1 proposal for adding a more limited value-semantic type.

Theory

Tuples are finite ordered sequences of values that serve two main purposes in programming languages:

  • grouping together heterogenous (mixed) data types within a static type system (this doesn't apply to a dynamically typed language like JavaScript)
  • simplifying value-semantic comparisons of lists, which is what this library is mainly about

Value semantics

A simple way to explain value semantics is to look at the difference between primitive values (like numbers and strings) and object values in JavaScript. Primitives are value-semantic by default, meaning that the default comparison methods (==, === and Object.is()) compare primitive values by their contents, so, for example, any string is equal to any other string created with the same contents:

'abc' === 'abc'; // β†’ true, because both string literals create a value with the same contents

The contents of primitive values are also immutable (can't change at runtime), so the results of comparing primitive value equality can't be invalidated by the contents of the values changing.

Meanwhile, each object value (instance) in JavaScript has a unique identity, so each instance is only equal to itself and not any other instances:

[1, 2, 3] === [1, 2, 3]; // β†’ false, because both array literals create separate array instances

Objects by default can't be thought of as their contents since the contents can change, and this is called reference semantics, since objects essentially represent a place in memory. The downside is that it makes reasoning about a program harder, since the programmer has to consider potential changes.

A more direct practical consequence of reference semantics is that comparing instances requires deep comparisons, such as _.isEqual() in lodash or serializing the object values to JSON:

let a = [1, 2, 3];
let b = [1, 2, 3];
let result = JSON.stringify(a) === JSON.stringify(b); // β†’ true, because it's a deep comparison
a.push(4); // a and b contents are now different, so the cached comparison result is invalid

Deep comparison results can't be reliably cached since the compared instances can change, and it's also less efficient than just being able to use === directly. An another thing that's not possible with reference semantics is combining different values to use as a composite key (such as with Map or WeakMap).

Directed acyclic graphs

Directed acyclic graphs (DAGs) are a data structure that allows efficiently mapping a sequence of values to a unique object containing them, which is how this library is implemented. Specifically, it uses a WeakMap object (optionally a Map as well if mapping primitives) for each node, and the nodes are re-used for overlapping paths in the graph. Map access has constant time complexity, so the number of tuples created doesn't slow down access speed. Using WeakMap ensures that if the values used to create the tuple are dereferenced, the tuple object gets garbage collected.

Installation

npm

npm install tuplerone

yarn

yarn add tuplerone

CDN

https://unpkg.com/tuplerone/dist/tuplerone.umd.js

Usage

Tuple(…values)

import { Tuple } from 'tuplerone';

// Dummy objects
const a = Object('a');
const b = Object('b');
const c = Object('c');

// Structural equality testing using the identity operator
Tuple(a, b, c) === Tuple(a, b, c); // β†’ true
Tuple(a, b) === Tuple(b, a); // β†’ false

// Mapping using a pair of values as key
const map = new Map();
map.set(Tuple(a, b), 123).get(Tuple(a, b)); // β†’ 123

// Nesting tuples
Tuple(a, Tuple(b, c)) === Tuple(a, Tuple(b, c)); // β†’ true

// Using primitive values
Tuple(1, 'a', a); // β†’ Tuple(3) [1, "a", Object("a")]

// Indexing
Tuple(a, b)[1]; // β†’ Object("b")

// Checking arity
Tuple(a, b).length; // β†’ 2

// Failing to mutate
Tuple(a, b)[0] = c; // throws an error

The tuple function caches or memoizes its arguments to produce the same tuple object for the same arguments.

Types

The library is well-typed using TypeScript:

import { Tuple, Tuple0, Tuple1, Tuple2 } from 'tuplerone';

// Dummy object for use as key
const o = {};

const tuple0: Tuple0 = Tuple(); // 0-tuple
const tuple1: Tuple1<typeof o> = Tuple(o); // 1-tuple
const tuple2: Tuple2<typeof o, number> = Tuple(o, 1); // 2-tuple

Tuple(o) === Tuple(o, 1); // TS compile error due to different arities

// Spreading a TypeScript tuple:
Tuple(...([1, 2, 3] as const)); // β†’ Tuple3<1, 2, 3>

In editors like VS Code, the type information is also available when the library is consumed as JavaScript.

CompositeSymbol(…values)

It's possible to avoid creating an Array-like tuple for cases where iterating the tuple members isn't needed (for example, just to use it as a key):

import { CompositeSymbol } from 'tuplerone';

typeof CompositeSymbol(1, 2, {}) === 'symbol'; // β†’ true

A symbol is more space efficient than a tuple and can be used as a key for plain objects.

ValueObject(object)

Tuplerone also includes a simple value object implementation:

import { ValueObject } from 'tuplerone';

ValueObject({ a: 1, { b: { c: 2 } }}) === ValueObject({ a: 1, { b: { c: 2 } }}); // β†’ true

Note that the passed objects are frozen with Object.freeze().

Caveats

Since this is a userspace implementation, there are a number of limitations.

At least one member must be an object to avoid memory leaks

Due to WeakMap being limited to using objects as keys, there must be at least one member of a tuple with the object type, or the tuples would leak memory. Trying to create tuples with only primitive members will throw an error.

Tuple(1, 2); // throws TypeError
Tuple(1, 2, {}); // works

WeakMap is an ES2015 feature which is difficult to polyfill (the polyfills don't support frozen objects), but this applies less to environments like node or browser extensions.

UnsafeTuple

There is an UnsafeTuple type for advanced use cases where the values not being garbage-collectable is acceptable, so it doesn't require having an object member:

import { UnsafeTuple as Tuple } from 'tuplerone';

Tuple(1, 2, 3) === Tuple(1, 2, 3); // β†’ true

Can't be compared with operators like < or >

tuplerone tuples are not supported by the relation comparison operators like <, whereas in a language like Python the following (comparing tuples by arity) would evaluate to true: (1,) < (1, 2).

Array-like but there's no Array prototypes methods

Tuples subclass Array:

Array.isArray(Tuple()); // β†’ true

Yet tuples don't support mutative Array prototype methods like Array.sort(), since tuples are frozen.

The advantage of subclassing Array is ergonomic console representation (it's represented as an array would be), which is based on Array.isArray() and so requires subclassing Array.

Limited number of arities

The tuples are currently typed up to 8-tuple (octuple) because TypeScript doesn't yet support variadic generics. The types are implemented using function overloads.

instanceof doesn't work as expected

Tuples can be constructed without the new keyword to make them behave like other primitive values (Symbol, Boolean, String, Number) that also don't require new and also are value-semantic. This means that instanceof doesn't work the same as for other objects, but can still be used like so:

Tuple() instanceof Tuple.constructor; // β†’ true

License

MIT

Author

slikts [email protected]