Skip to content
/ prefab Public

TypeScript library for optimized data structures.

License

Notifications You must be signed in to change notification settings

zimmed/prefab

Repository files navigation

@zimmed/prefab

TypeScript library for optimized data structures (prefabs).

Typedocs

Jest Branch Coverage Jest Function Coverage Jest Line Coverage Jest Statement Coverage

Installation

First, edit existing or create new .npmrc file in your project root, and add:

@zimmed:registry=https://npm.pkg.github.com

Then you can use:

$ npm i --save @zimmed/prefab

iFAQ

Do I have to use TypeScript to use this library?

Nope! You can import into any ES6 project using the same import syntax listed below, into Node.js using   const { LinkedList } = require('@zimmed/prefab'), or directly into a modern browser environment using  <script src="prefab.umd.js" /><script>const list = new prefab.LinkedList();</script>. If you want ES5 backward compatibility, you will need to bundle this library with your own transpilation process (see babel).

Why are you reinventing the wheel?

There are many JavaScript examples and libraries for various datastructures, but very few with full typing support, and even fewer that are optimized for raw performance. These prefabs take advantage of the highly-optimized ES5+ builtins, like Set and Map and extend their behavior to be a bit more versatile, as in the case of the LinkedSet, which sparked this library because I wanted the performance advantages of the JavaScript Set object, but needed to be able to pop elements from it (which the Set does not do natively).

If you still need convincing, perhaps the performance benchmarks that will be published with the upcoming 0.2.0 release will help clarify. In preliminary tests on Node.js 16.4, the LinkedSet maintained overall Set performance, while DRASTICALLY (to the tune of 60x) outperformed even the native Array on unshift and shift operations, thanks to the doubly-LinkedList.

What tool are you using to benchmark?

@zimmed/bench

How can I help?

Report any bugs you come across on the issues page, or suggest enhancements.

Classical inheritance sucks!

Not a question, but I completely agree. Object composition is a much better pattern. That said, because TypeScript is geared more towards the traditional (read: wrong) OOP opproach, it works much better for these kinds of libraries when you have a lot of generic types to use a hierarchical approach. It also makes the code very familiar for a larger set of developers.

I considered adding parallel factory patterns instead of instance-based structures, but this library is really about performance, which is optimized very well on any modern JavaScript engine for class instantiation, and switching to a more robust and testable stateless factory pattern would hurt that peformance. I may still do this anyway at some point, but it's not a priority.

Prefabs

Inheritance Tree

 LinkedList
 ┃
 ┣━ SizedLinkedList
 ┃  ┻━ Queue [Future]
 ┃
 ┣━ LinkedSet
 ┃  ┃
 ┃  ╋━ UniQueue [Future]
 ┃  ┃
 ┃  ╋━ SortedSet [Future]
 ┃  ╹  ┻━ PriorityQueue [Future]
 ┃
 ┣━ LinkedCollection
 ┃  ┃
 ╹  ┻━ SortedCollection [Future]

 ObjectPool < LinkedList

LinkedList (docs) (src)

For all your linked-list needs!

import { LinkedList } from '@zimmed/prefab';

const foo = new LinkedList<string>()
  .add('green')
  .add('eggs')
  .add('and')
  .cycle()
  .add('spam')
  .cycle()
  .join(' '); // -> "spam and green eggs"

const bar = LinkedList.from('green eggs and spam'.split(' ')); // -> LinkedList { 'green' 'eggs' 'and' 'spam' }

for (const s of bar) {
  // ... iteration
}

SizedLinkedList (docs) (src)

(extends LinkedList)

A very slight extension to the LinkedList prefab that tracks the size of the list.

import { SizedLinkedList as LinkedList } from '@zimmed/prefab';

const baz = LinkedList.from('green eggs and spam'.split(' ')).size; // -> 4

LinkedSet (docs) (src)

(extends LinkedList)

Combines the optimized functionality of the builtin Set, with the versatility of a linked list: Best of both worlds! Blazing fast unique list operations and lookups while supporting additional functionality that the native Set does not support, such as insert, pop, shift, and more.

import { LinkedSet } from '@zimmed/prefab';

const set = new LinkedSet(['one', 'two', 'three', 'two', 'four', 'one']); // -> LinkedSet { 'one' 'two' 'three' 'four' }

set.add('five');
set.size; // -> 5

const obj = set.reduce(
  (
    accum,
    key,
    idx /** Note: 2nd param to callbacks is INDEX not the redundant key as it is in the builtin Set **/
  ) => ({
    ...accum,
    [key]: idx,
  }),
  {} as { [k: string]: number }
);

const five = set.pop(); // -> 'five'

set.size; // -> 4

LinkedCollection (docs) (src)

(extends LinkedList)

Similar to the LinkedSet, but uses primary key lookups and identifiers, and requires items to be objects. Useful for keeping key -> value records with constant lookups, as well as linked list iterations.

import { LinkedCollection } from '@zimmed/prefab';

class Item {
  constructor(
    public readonly id: string,
    public data?: any,
    public readonly cat: string = 'default'
  ) {}
}

const collection = new LinkedCollection('id', [
  new Item('one'),
  new Item('two'),
  new Item('three'),
  new Item('two'),
]); // -> LinkedCollection { Item('one'), Item('two'), Item('three') }

collection.add(new Item('four'));
collection.size; // -> 4

for (const item of collection) {
  // same as collection.values()
  // -> Item('one'), Item('two'), Item('three'), ...
}

for (const key of collection.keys()) {
  // -> 'one', 'two', 'three', ...
}

for (const [key, item] of collection.entries()) {
  // -> ['one', Item('one')], ...
}

// Respects same Set methods
const four = collection.pop(); // -> Item('four')
collection.size; // -> 4

// Introduces expected Map method and changes signatures to be key lookups instead of item lookups
collection.select('two'); // -> Item('two')
collection.has('one'); // -> true
collection.delete('three'); // -> true

// Upserting / Uppending
// Safely update existing item or add/insert new one
collection.uppend(new Item('seven')); // -> same as collection.add or collection.append
collection.uppend(new Item('seven', { foo: 'bar' })); // -> replaces existing Item('seven') with new Item('seven', { foo: 'bar' }) maintaining link position
collection.upsert(new Item('zero')); // -> same as collection.insert or collection.unshift
collection.upsert(new Item('zero', 14)); // -> updates existing Item('zero') with new Item('zero', 14) maintaining link position

// Additional groupBy Helper
// Groups items into arrays based on provided key
new LinkedCollection('id', [
  new Item('one'),
  new Item('two'),
  new Item('three', undefined, 'special'),
]).groupBy('cat'); // -> { default: [Item('one'), Item('two')], special: [Item('three')] }
// if key is the collection identifier key, returns a key->value object
new LinkedCollection('id', [new Item('one'), new Item('two')]).groupBy('id'); // -> { one: Item('one'), two: Item('two') }

ObjectPool (docs) (src)

(uses LinkedList)

A pooled object manager for consistent memory signatures.

import { ObjectPool } from '@zimmed/prefab';

let counter = 0;

export class MyEntity extends ObjectPool.Object {
  id = 'entity:my';
  uid = ++counter;
  location = [-1, -1];
  health = 0;
  name = 'unnamed';

  get alive() {
    return this.health > 0;
  }

  distance([x, y]: [number, number]) {
    return Math.sqrt((x - this.location[0]) ** 2 + (y - this.location[1]) ** 2);
  }

  onInit(name: string, x: number, y: number, health = 100) {
    this.name = name;
    this.health = health;
    this.location[0] = x;
    this.location[1] = y;
  }

  // Be sure to free up any references that are no longer needed so they can be garbage collected.
  onClean() {
    this.health = 0;
    this.location[0] = -1;
    this.location[1] = -1;
  }
}

const entityPool = ObjectPool.create(MyEntity, 1000); // pre-allocates 1000 entities

entityPool.alloc(1000); // allocates 1000 more entities
entityPool.size; // -> 2000

// Take and initialize objects for use from pool
const dude = entityPool.spawn('Dude', 4, 5);
const body = entityPool.spawn('Dead Guy', 3, 3, 0);

Array(1998)
  .fill(0)
  .map(() => entityPool.spawn('Used Entity', 100, 100)); // Use up entire pool

let newEntity = entityPool.spawn('Another entity', 1, 1); // No entity created! Returns undefined when all objects used up.
newEntity = entityPool.forceSpawn('Another entity', 1, 1); // Entity created! Forces a creation when no objects available and increases the max size of the pool.

entityPool.size; // -> 2001

entityPool.realloc(1000); // No change to pool
// entityPool.reallocUnsafe(1000);
// This will release all entities over the new maximum, keeping the most recently allocated first.
// This would break the game loop, as `dude` and `body` would all be cleaned and orphaned objects.
// The same can also be achieved with `entityPool.dealloc(1001);`
// Alternatively, `entityPool.clear()` will free all pooled objects.
entityPool.realloc(1e6); // Set pool size to 1,000,000 (keeps existing entities)

function gameLoop(player) {
  if (body.distance(player.location) === 0) {
    entityPool.free(body); // Frees object back into pool for re-use later
    console.log(body.location); // -> [-1, -1];
  }
}

Performance Benchmarks

(coming soon...)