| name | base |
| description | Use when reaching for @toolcase/base — zero-dep TypeScript helpers + data structures (Cache, PriorityQueue, RingBuffer, Stack, Deque, VectorClock, State, AdjacencyMatrix, ObjectPool, WeightedRandom, BiMap, BloomFilter, MultiMap), events (EventEmitter, Broadcast), pathfinding (Dijkstra, AStar — class-based, step()-controlled, event-emitting), rectangle/atlas packing (Packing.Packer + MaxRects/Guillotine/Shelf/Skyline/BinaryTree algorithms, multi-page, POT, trim/extrude), spatial partitioning (Spatial.SpatialHash grid + Spatial.Quadtree — insert/remove/update/range-query/nearest-neighbour), async utilities (Deferred, Semaphore, Mutex, pLimit, withTimeout, sleep, debounce, throttle, AsyncQueue — backpressure-aware producer/consumer channel), easing functions (30 easeIn*/easeOut*/easeInOut* functions for Sine/Quad/Cubic/Quart/Quint/Expo/Circ/Back/Elastic/Bounce families plus a CSS-compatible cubicBezier sampler — all exported individually and as Easing namespace), scalar math helpers (clamp, lerp, inverseLerp, mapRange, smoothstep, approximately), string helpers (slugify — URL-safe slug; truncate — length-limited string with suffix; escapeHtml — XSS-safe HTML escaping for & < > \" '), utilities (generateId, retry, hex/byte/range helpers, diff — structural delta for plain objects/arrays, patch — apply delta so patch(a,diff(a,b)) deep-equals b), timing (Stopwatch — start/stop/lap/elapsed with injectable clock; Ticker — fixed-step/variable-step update dispatcher driven by tick(delta)), JSONSchema validation, LSystem, Color palette, HTTP REST primitives, tagged-union helpers Result<T,E> (ok/err constructors, isOk/isErr, map/mapErr, andThen/flatMap, unwrap/unwrapOr/unwrapErr) and Option<T> (some/none constructors, isSome/isNone, map, andThen/flatMap, unwrap/unwrapOr), and BPlusIndex — persistent ordered B+ tree key-value index with MemoryAdapter/FsAdapter/OpfsAdapter/LocalStorageAdapter storage backends and PageCache LRU buffer pool (set/get/has/delete, setMany/getMany/deleteMany, first/last/floor/ceil/rank/nth/count/range, entries/keys/values, flush/close/clear/compact/stats). |
base — API Reference
Zero-dependency TypeScript helpers and data structures. Isomorphic (Node + browser). Single import:
import {
HTTP,
Packing,
Spatial,
Async,
Easing,
VectorClock, EventEmitter, Broadcast,
LSystem, ObjectPool, PriorityQueue, RingBuffer, Stack, Deque,
generateId, ulid, toHex, formatByteSize, formatDuration, formatNumber, relativeTime,
bufferToHex, hexToBuffer,
slugify, truncate, escapeHtml,
Color, JSONSchema, getNumberInRange,
clamp, lerp, inverseLerp, mapRange, smoothstep, approximately,
easeInSine, easeOutSine, easeInOutSine,
easeInQuad, easeOutQuad, easeInOutQuad,
cubicBezier,
Cache, AdjacencyMatrix, State, retry,
WeightedRandom, Dijkstra, AStar,
DisjointSet, Trie,
BiMap, BloomFilter, MultiMap,
Vec2,
ok, err,
some, none,
TokenBucket,
Stopwatch,
Ticker,
diff,
patch,
BPlusIndex, MemoryAdapter, FsAdapter, OpfsAdapter, LocalStorageAdapter, PageCache,
} from '@toolcase/base'
import type { Result, Option, Delta, StorageAdapter, BPlusIndexOptions, RangeOptions, BPlusIndexStats } from '@toolcase/base'
Table of Contents
Data Structures
Cache
Async memoization with TTL. Key is JSON-stringified args.
new Cache<T>(fetchFn: (...args: any[]) => T | Promise<T>, ms: number = 0)
get(...args): Promise<T | null> — returns cached value if now <= fetchedAt + ms, else calls fetchFn(...args), stores, returns.
setMS(ms: number) — change TTL window.
invalidate(...args) — drop one entry by argument hash.
const userCache = new Cache(async (id: string) => fetchUser(id), 60_000)
const u = await userCache.get('42')
userCache.invalidate('42')
ms = 0 ⇒ effectively no caching across millisecond boundaries (entries expire when now > fetchedAt). Two calls within the same millisecond still share a result; in practice this means each call re-fetches. Use setMS(0) to force-stale a cache; use invalidate(...args) to drop a single entry. Constructor throws if fetchFn is not a function.
PriorityQueue
Min-heap. Smallest priority dequeues first.
new PriorityQueue<T>(priorityFn: (node: T) => number, uniqueFn?: (node: T) => string)
length: number — read-only count.
enqueue(value: T): boolean — returns true; throws if value === undefined.
dequeue(): T | null — remove + return root (lowest priority).
pop(): T | null — remove last raw element (no heap restoration).
has(value: T): boolean | null — only when uniqueFn provided; else null.
const pq = new PriorityQueue<{ id: string, weight: number }>(n => n.weight, n => n.id)
pq.enqueue({ id: 'a', weight: 5 })
pq.enqueue({ id: 'b', weight: 1 })
pq.dequeue()
RingBuffer
Fixed-capacity circular buffer. Oldest entry is overwritten when the buffer is full. Zero allocations after construction.
new RingBuffer<T>(capacity: number)
capacity: number — (readonly) maximum number of items.
size: number — current item count.
push(item: T): this — append an item; overwrites the oldest when full. Throws if item === undefined. Chainable.
peek(): T | null — the oldest item without removing it; null when empty.
tail(n: number): T[] — the last n items in insertion order (newest last); clamped to size. Returns [] when n <= 0 or buffer is empty.
clear(): this — reset to empty; capacity is preserved.
[Symbol.iterator] — iterate all items in insertion order (oldest → newest).
Constructor throws if capacity is not a positive integer.
import { RingBuffer } from '@toolcase/base'
const rb = new RingBuffer<number>(3)
rb.push(1).push(2).push(3)
rb.peek()
rb.tail(2)
[...rb]
rb.push(4)
rb.peek()
[...rb]
rb.clear()
rb.size
Stack
LIFO stack backed by a plain array.
new Stack<T>()
size: number — current item count.
push(item: T): this — push to the top. Throws if item === undefined. Chainable.
pop(): T | null — remove and return the top item; null when empty.
peek(): T | null — top item without removing it; null when empty.
isEmpty(): boolean — true when size === 0.
clear(): this — reset to empty. Chainable.
[Symbol.iterator] — iterate all items in insertion order (bottom → top).
import { Stack } from '@toolcase/base'
const history = new Stack<string>()
history.push('login').push('dashboard').push('settings')
history.peek()
history.pop()
history.size
[...history]
history.clear()
history.isEmpty()
Deque
Double-ended queue backed by a doubly-linked list. O(1) push and pop at both ends.
new Deque<T>()
size: number — current item count.
pushFront(item: T): this — insert at the front. Throws if item === undefined. Chainable.
pushBack(item: T): this — insert at the back. Throws if item === undefined. Chainable.
popFront(): T | null — remove and return the front item; null when empty.
popBack(): T | null — remove and return the back item; null when empty.
peekFront(): T | null — front item without removing it; null when empty.
peekBack(): T | null — back item without removing it; null when empty.
isEmpty(): boolean — true when size === 0.
clear(): this — reset to empty. Chainable.
[Symbol.iterator] — iterate all items front to back.
import { Deque } from '@toolcase/base'
const d = new Deque<number>()
d.pushBack(2).pushBack(3).pushFront(1)
d.peekFront()
d.peekBack()
[...d]
d.popFront()
d.popBack()
d.size
d.clear()
d.isEmpty()
Use as a FIFO queue: pushBack + popFront. Use as a LIFO stack: pushBack + popBack (or pushFront + popFront). Sliding-window algorithms use both ends simultaneously.
VectorClock
Distributed-systems clock for causality tracking.
new VectorClock(nodeId: string, data?: Record<string, number>)
Instance:
setClock(clock) / getClock(): Record<string, number> (copy).
setVersion(version, nodeId = this.nodeId) / getVersion(nodeId = this.nodeId): number.
increment() — bump own node.
update(other: VectorClock) — pairwise max over union of node ids.
isAfter(other) / isConcurrent(other) / isBefore(other): boolean.
Static:
VectorClock.getNodeIds(a, b): string[]
VectorClock.isAfter(a, b) / isConcurrent(a, b) / isBefore(a, b): boolean
VectorClock.compare(a, b): 1 | 0 | -1 — after / concurrent / before.
const a = new VectorClock('node-a'); a.increment()
const b = new VectorClock('node-b'); b.update(a); b.increment()
b.isAfter(a)
State
Observable state (extends Broadcast). Deep-merge set() emits dotted-path events.
new State<T extends Record<string, any>>(data: Partial<T> = {})
get(): Partial<T> — current data.
set(data: Partial<T>, emit = true): this — deep-merge; throws on type mismatch between existing object/primitive vs incoming.
empty(emit = true): this — wipe.
Events emitted on every nested key path: top-level event is 'state', then per property 'state.x', 'state.x.y', etc.
const state = new State({ player: { score: 0 } })
state.on('state.player.score', score => console.log(score))
state.set({ player: { score: 10 } })
AdjacencyMatrix
Graph as vertices[] + (P | N)[][] matrix. Default P = true, N = false; pass typed defaults to store edge metadata.
new AdjacencyMatrix<P = boolean, N = boolean>(defaultPositive?: P, defaultNegative?: N)
vertices: string[] (read-only).
addVertex(name): boolean — false if exists.
removeVertex(name): boolean.
addEdge(a, b, value = defaultPositive): boolean — directed; false if either vertex missing.
removeEdge(a, b): boolean.
getEdge(a, b): P | N — defaults to defaultNegative when missing.
getEdges(vertex): string[] — outgoing neighbors (where edge ≠ defaultNegative).
hasEdge(a, b): boolean.
Weighted graph:
const g = new AdjacencyMatrix<number, null>(1, null)
g.addVertex('A'); g.addVertex('B')
g.addEdge('A', 'B', 7)
g.getEdge('A', 'B')
ObjectPool
Reuses class instances. Pool stores released instances; auto-creates on demand.
new ObjectPool<T>(
objectClass: new () => T,
resetFn?: (object: T) => void,
instanceFn?: (Class: new () => T) => T
)
instances: number — total instances ever created.
obtain(): T — pop existing or create new. Each instance is auto-augmented with a release() method bound to the pool. Throws if the object already defines release.
release(object: T): this — calls resetFn, returns to pool.
dispose() — clear pool (does not destroy outstanding instances).
class Bullet { x = 0; y = 0 }
const pool = new ObjectPool(Bullet, b => { b.x = 0; b.y = 0 })
const b = pool.obtain()
b.release()
WeightedRandom
Weighted random selection with O(log n) picks. Builds a cumulative-weight table once and binary-searches a uniform random sample on each pick.
new WeightedRandom<T>(
items: Iterable<T>,
weightFn: (item: T) => number,
random: () => number = Math.random
)
length: number — items kept (zero-weight items are dropped at construction).
totalWeight: number — sum of positive weights.
pick(): T — single weighted draw.
pickMany(count: number): T[] — count independent draws (with replacement). Throws if count is not a non-negative integer.
probabilityOf(predicate: (item: T) => boolean): number — share of total weight matching the predicate, in [0, 1].
Constructor throws when weightFn/random are not functions, when any weight is negative or non-finite (Infinity/NaN), or when no item has a positive weight. Items with weight === 0 are silently skipped.
const loot = new WeightedRandom(
[
{ id: 'common', weight: 70 },
{ id: 'rare', weight: 25 },
{ id: 'epic', weight: 5 }
],
(entry) => entry.weight
)
loot.pick().id
loot.pickMany(3)
loot.probabilityOf(e => e.id === 'epic')
Pass a seeded random for deterministic tests:
let s = 1
const seeded = () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296 }
const wr = new WeightedRandom(['a', 'b'], (k) => k === 'a' ? 1 : 9, seeded)
DisjointSet
Union-Find with path compression and union-by-rank. Tracks disjoint sets identified by string keys.
new DisjointSet()
count: number — (readonly getter) number of disjoint sets currently tracked.
makeSet(id: string): this — register id as a new singleton set. No-op if id already exists. Chainable.
find(id: string): string | null — return the representative (root) of the set containing id; null if id was never registered. Applies path compression on every call.
union(a: string, b: string): boolean — merge the sets containing a and b. Returns true if they were in different sets (a merge happened), false if they were already in the same set or either id is unknown.
connected(a: string, b: string): boolean — true if a and b share a representative (same set); false otherwise or if either is unknown.
import { DisjointSet } from '@toolcase/base'
const ds = new DisjointSet()
ds.makeSet('a').makeSet('b').makeSet('c').makeSet('d')
ds.count
ds.union('a', 'b')
ds.union('c', 'd')
ds.count
ds.union('a', 'c')
ds.count
ds.connected('b', 'd')
ds.connected('a', 'z')
ds.find('b')
Trie
Prefix tree for O(m) insert, lookup, and delete (m = word length) and O(m + k) prefix enumeration (k = number of matches).
new Trie()
size: number — number of distinct words currently stored.
insert(word: string): this — add word to the trie. No-op if already present. Chainable.
has(word: string): boolean — true only when word was inserted and not deleted (exact match, not prefix match).
delete(word: string): boolean — remove word; returns true if it existed, false otherwise. Prunes nodes that are no longer on any other path.
startsWith(prefix: string): string[] — all inserted words that begin with prefix (including prefix itself if it was inserted). Empty prefix returns every word. Returns [] when no match exists.
clear(): this — remove all words. Chainable.
import { Trie } from '@toolcase/base'
const t = new Trie()
t.insert('apple').insert('app').insert('application').insert('apt')
t.has('apple')
t.has('app')
t.has('ap')
t.startsWith('app')
t.startsWith('apt')
t.startsWith('xyz')
t.startsWith('')
t.delete('app')
t.has('app')
t.has('apple')
t.size
BiMap
Bidirectional map enforcing a strict 1-to-1 (bijective) relationship between keys and values. Both directions are stored as Maps, so all lookups are O(1). Assigning a key to a new value (or a value to a new key) automatically displaces the existing pair.
new BiMap<K, V>()
size: number — number of key-value pairs.
set(key: K, value: V): this — insert or replace the pair. If value was already mapped to a different key, that old key is removed. If key was already mapped to a different value, that old value is removed. Chainable.
get(key: K): V | null — forward lookup; null when key not present.
getKey(value: V): K | null — reverse lookup; null when value not present.
has(key: K): boolean — true when key exists.
hasValue(value: V): boolean — true when value exists.
delete(key: K): boolean — remove the pair by key; true if found.
deleteByValue(value: V): boolean — remove the pair by value; true if found.
clear(): this — remove all pairs. Chainable.
[Symbol.iterator] — iterate [K, V] pairs in insertion order.
import { BiMap } from '@toolcase/base'
const ports = new BiMap<string, number>()
ports.set('http', 80).set('https', 443).set('ftp', 21)
ports.get('https')
ports.getKey(80)
ports.hasValue(443)
ports.set('alt-http', 80)
ports.has('http')
ports.getKey(80)
ports.size
ports.delete('ftp')
ports.size
for (const [svc, port] of ports) {
console.log(svc, '->', port)
}
MultiMap
Maps each key to a Set of values. size counts total values across all keys. Adding the same key-value pair twice is a no-op (set semantics per key). Supports per-value removal or bulk removal of all values under a key.
new MultiMap<K, V>()
size: number — total number of values across all keys.
set(key: K, value: V): this — add value to the set under key. No-op if the pair already exists. Chainable.
get(key: K): ReadonlySet<V> | undefined — the set of values under key; undefined when key not present.
has(key: K, value?: V): boolean — with only key: true when any values exist under it. With value: true when that specific pair exists.
delete(key: K, value?: V): boolean — with only key: remove all values under it (returns true if key existed). With value: remove that specific value (returns true if found; removes the key when its set becomes empty).
keys(): IterableIterator<K> — iterate all keys that have at least one value.
clear(): this — remove all entries. Chainable.
[Symbol.iterator] — iterate [K, ReadonlySet<V>] pairs.
import { MultiMap } from '@toolcase/base'
const tags = new MultiMap<string, string>()
tags.set('post:1', 'typescript').set('post:1', 'react')
tags.set('post:2', 'typescript').set('post:2', 'css')
tags.size
tags.get('post:1')
tags.has('post:1', 'react')
tags.has('post:1', 'vue')
tags.delete('post:1', 'react')
tags.get('post:1')
tags.size
tags.delete('post:2')
tags.has('post:2')
tags.size
for (const [postId, tagSet] of tags) {
console.log(postId, [...tagSet])
}
Events
EventEmitter
Minimal typed emitter. Vendored — zero deps.
import { EventEmitter } from '@toolcase/base'
const ee = new EventEmitter()
ee.on(event, fn, context?)
ee.once(event, fn, context?)
ee.off(event, fn?, context?)
ee.emit(event, ...args): boolean
ee.removeAllListeners(event?): this
ee.listenerCount(event): number
ee.eventNames(): (string | symbol)[]
event is string | symbol. context defaults to the emitter itself.
Broadcast
Base class wrapping EventEmitter with a protected emit() — extend it to expose only on/off/once to consumers while keeping emission internal.
class Service extends Broadcast {
doWork() {
this.emit('done', { ok: true })
}
}
Public surface mirrors EventEmitter (on/off/once/removeAllListeners/listenerCount/eventNames).
Pathfinding
Two graph-search classes that work on any caller-supplied graph adapter (no coupling to AdjacencyMatrix). Pass a neighbors iterable + a cost function; for AStar, also a heuristic. Non-string nodes need a hash to deduplicate visits.
Both extend EventEmitter, expose a manual step() for cooperative scheduling (one expansion per call — game loops, web workers, time-budgeted ticks), and emit lifecycle events. AStar extends Dijkstra — same surface plus the heuristic. Both reuse PriorityQueue internally; costs must be non-negative and finite.
type Neighbors<N> = (node: N) => Iterable<N>
type EdgeCost<N> = (from: N, to: N) => number
type NodeHash<N> = (node: N) => string
interface PathResult<N> {
path: N[]
cost: number
}
type SearchStatus = 'searching' | 'found' | 'failed'
type FailReason = 'exhausted' | 'max_iterations'
Shared API
| Member | Description |
|---|
start: N / end: N | (readonly) inputs |
iterations: number | nodes visited so far |
maxIterations: number | cap; default Infinity. Set to bound work per query |
isComplete: boolean | true once status is found or failed |
getStatus(): SearchStatus | current state |
step(): SearchStatus | run one expansion; returns new status |
run(maxSteps?): PathResult<N> | null | step until complete or maxSteps reached |
getResult(): PathResult<N> | null | final path (only after 'found') |
Events (mirrored on both classes)
| Constant | Payload | Fires |
|---|
Dijkstra.VISIT | (node, gCost) | each time a node is popped from the frontier |
Dijkstra.OPEN | (node, gCost) | each time a neighbor is enqueued or its cost improved |
Dijkstra.FOUND | (PathResult) | once, when goal reached |
Dijkstra.FAILED | (FailReason) | once, when frontier exhausts or maxIterations exceeded |
AStar.FOUND === Dijkstra.FOUND, etc. Listeners subscribe via .on(Dijkstra.FOUND, fn) / .once(...). Subclass-friendly: priorityOf, relax, seed, reconstruct, fail are all protected so phaser-plus AI modules can override (e.g. plug a pooled-node frontier).
Dijkstra
Lowest-cost path on a weighted directed graph. Optimal when all edge costs are non-negative.
class Dijkstra<N> extends EventEmitter {
constructor(start: N, end: N, options: DijkstraOptions<N>)
static find<N>(start, end, options): PathResult<N> | null
}
interface DijkstraOptions<N> {
neighbors: Neighbors<N>
cost: EdgeCost<N>
hash?: NodeHash<N>
}
Throws when neighbors/cost are missing or when an edge cost is negative / non-finite (the throw happens during step() / run(), not at construction — only the option callbacks are checked up front).
import { Dijkstra } from '@toolcase/base'
const edges: Record<string, [string, number][]> = {
A: [['B', 1], ['C', 4]],
B: [['C', 2], ['D', 5]],
C: [['D', 1]],
D: []
}
const result = Dijkstra.find('A', 'D', {
neighbors: (n) => (edges[n] ?? []).map(([t]) => t),
cost: (from, to) => edges[from].find(([t]) => t === to)![1]
})
result?.path
result?.cost
const search = new Dijkstra('A', 'D', {
neighbors: (n) => (edges[n] ?? []).map(([t]) => t),
cost: (from, to) => edges[from].find(([t]) => t === to)![1]
})
search.on(Dijkstra.VISIT, (node, g) => console.log('visit', node, 'g=', g))
search.on(Dijkstra.FOUND, (path) => console.log('done', path))
while (!search.isComplete) search.step()
AStar
A* — Dijkstra plus a heuristic lower bound on remaining cost. Optimal when the heuristic is admissible (never overestimates true remaining cost). Faster than Dijkstra on grids / spatial graphs because it expands toward the goal first.
class AStar<N> extends Dijkstra<N> {
constructor(start: N, end: N, options: AStarOptions<N>)
static find<N>(start, end, options): PathResult<N> | null
}
interface AStarOptions<N> extends DijkstraOptions<N> {
heuristic: (node: N, goal: N) => number
}
Throws when heuristic is missing at construction; throws during stepping if the heuristic returns negative / non-finite or if an edge cost is negative.
import { AStar } from '@toolcase/base'
type Cell = { x: number, y: number }
const SIZE = 5
const inBounds = (c: Cell) => c.x >= 0 && c.y >= 0 && c.x < SIZE && c.y < SIZE
const result = AStar.find({ x: 0, y: 0 }, { x: 4, y: 4 }, {
neighbors: (n) => [
{ x: n.x + 1, y: n.y },
{ x: n.x - 1, y: n.y },
{ x: n.x, y: n.y + 1 },
{ x: n.x, y: n.y - 1 }
].filter(inBounds),
cost: () => 1,
heuristic: (a, b) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y),
hash: (n) => `${n.x},${n.y}`
})
result?.cost
result?.path.length
Time-budgeted scheduling (e.g. game loops / phaser-plus AI modules):
const search = new AStar(start, end, opts)
const BUDGET_MS = 2
function tick() {
const t0 = performance.now()
while (!search.isComplete && performance.now() - t0 < BUDGET_MS) {
search.step()
}
if (search.isComplete) emitFinalPath(search.getResult())
}
Common admissible heuristics: Manhattan (4-connected grid), Chebyshev (8-connected grid), Euclidean (any-angle / continuous). A heuristic that returns 0 reduces A* to Dijkstra — just use Dijkstra instead.
Utilities
generateId
Crypto-random hex id.
generateId(length: number = 8): string
Uses globalThis.crypto.getRandomValues. length is the string length (rounded up to whole bytes internally, sliced).
ulid
Monotonic sortable ID. 26-character Crockford Base32 string: 10-char millisecond timestamp prefix + 16-char random suffix. Lexicographic sort equals time order. Within the same millisecond the random suffix is incremented, guaranteeing strict monotonicity even under rapid-fire generation.
ulid(): string
Uses globalThis.crypto.getRandomValues for the random component. Throws Error('ulid overflow: too many ids within the same millisecond') if 32^16 IDs are generated in a single millisecond (unreachable in practice).
import { ulid } from '@toolcase/base'
ulid()
ulid()
const ids = [ulid(), ulid(), ulid()]
ids.slice().sort()
getNumberInRange
Parse-then-clamp.
getNumberInRange(value: string | number, defaultValue = 0, min = MIN_SAFE_INTEGER, max = MAX_SAFE_INTEGER): number
parseInt(value, 10) for strings; NaN ⇒ defaultValue. Result clamped via min/max.
retry
Exponential-backoff retry for sync or async functions.
retry<T>(fn: () => T | Promise<T>, options?: Partial<RetryOptions>): Promise<T>
interface RetryOptions {
retries: number
factor: number
minTimeout: number
maxTimeout: number
randomize: boolean
}
Wait between attempt N and N+1: min(random * minTimeout * factor^(N-1), maxTimeout). Throws final error after retries exhausted. Throws synchronously if minTimeout > maxTimeout.
Hex helpers
toHex(value: number, digits: number = 4): string
bufferToHex(buffer: Uint8Array): string
hexToBuffer(hex: string): Uint8Array
formatByteSize
formatByteSize(bytes: number, decimals: number = 2): string
Returns '0 Bytes' when bytes === 0. Powers of 1024: Bytes / KB / MB / GB / TB / PB / EB / ZB / YB.
formatByteSize(1536)
formatDuration
Convert a millisecond count to a compact, human-readable string. Shows the two most significant units; sub-second precision is dropped once the value reaches one second.
formatDuration(ms: number): string
Returns '0ms' when ms is 0, negative, or non-finite.
| Range | Format | Example |
|---|
< 1 s | Nms | '500ms' |
< 1 min | Ns | '45s' |
< 1 h | Nm Ns | '2m 30s' |
< 1 d | Nh Nm | '1h 30m' |
≥ 1 d | Nd Nh | '3d 2h' |
formatDuration(500)
formatDuration(90_000)
formatDuration(5_400_000)
formatDuration(90_000_000)
formatDuration(0)
formatNumber
Format a number with optional compact notation (k / M / B suffixes). In non-compact mode inserts thousands separators.
formatNumber(n: number, options?: FormatNumberOptions): string
interface FormatNumberOptions {
compact?: boolean
}
Returns '0' for NaN or ±Infinity. Compact mode rounds to one decimal place and drops trailing .0.
formatNumber(999)
formatNumber(1_000)
formatNumber(1_234_567)
formatNumber(1_200, { compact: true })
formatNumber(1_000, { compact: true })
formatNumber(3_400_000, { compact: true })
formatNumber(2_500_000_000, { compact: true })
formatNumber(-1_200, { compact: true })
relativeTime
Turn a Date or Unix-millisecond timestamp into a human-readable relative-time string such as '3 minutes ago' or 'in 2 hours'. Compares against Date.now() at call time.
relativeTime(date: Date | number): string
| Threshold | Format | Example |
|---|
< 1 s | 'just now' | 'just now' |
< 1 min | 'N second(s) ago' / 'in N second(s)' | '30 seconds ago' |
< 1 h | 'N minute(s) ago' / 'in N minute(s)' | 'in 5 minutes' |
< 1 d | 'N hour(s) ago' / 'in N hour(s)' | '2 hours ago' |
≥ 1 d | 'N day(s) ago' / 'in N day(s)' | 'in 3 days' |
const now = Date.now()
relativeTime(new Date(now - 500))
relativeTime(new Date(now - 30_000))
relativeTime(new Date(now - 3 * 60_000))
relativeTime(new Date(now - 2 * 3_600_000))
relativeTime(new Date(now - 3 * 86_400_000))
relativeTime(new Date(now + 5 * 60_000))
relativeTime(now + 2 * 3_600_000)
Math helpers
Zero-dependency scalar math. All functions are pure and accept / return plain number (or boolean for approximately).
import { clamp, lerp, inverseLerp, mapRange, smoothstep, approximately } from '@toolcase/base'
clamp
clamp(value: number, min: number, max: number): number
Constrain value to the closed interval [min, max].
clamp(15, 0, 10)
clamp(-5, 0, 10)
clamp(5, 0, 10)
lerp
lerp(a: number, b: number, t: number): number
Linear interpolation between a (at t=0) and b (at t=1). Extrapolates outside [0, 1].
lerp(0, 100, 0)
lerp(0, 100, 0.5)
lerp(0, 100, 1)
lerp(0, 100, 1.5)
inverseLerp
inverseLerp(a: number, b: number, value: number): number
Inverse of lerp — returns the t such that lerp(a, b, t) === value. Returns 0 when a === b (degenerate case).
inverseLerp(0, 100, 25)
inverseLerp(0, 100, 75)
inverseLerp(5, 5, 99)
mapRange
mapRange(value: number, inMin: number, inMax: number, outMin: number, outMax: number): number
Remap value from one range to another. Extrapolates when value is outside [inMin, inMax].
mapRange(0.5, 0, 1, 0, 255)
mapRange(128, 0, 255, 0, 1)
mapRange(5, 0, 10, -100, 100)
smoothstep
smoothstep(edge0: number, edge1: number, x: number): number
Smooth Hermite interpolation (Ken Perlin's cubic S-curve) returning 0 when x <= edge0, 1 when x >= edge1, and an ease-in/ease-out value in between. Output is clamped to [0, 1].
smoothstep(0, 1, 0)
smoothstep(0, 1, 0.25)
smoothstep(0, 1, 0.5)
smoothstep(0, 1, 0.75)
smoothstep(0, 1, 1)
approximately
approximately(a: number, b: number, epsilon: number = 1e-7): boolean
Returns true when |a - b| <= epsilon. Useful for float equality checks after arithmetic.
0.1 + 0.2 === 0.3
approximately(0.1 + 0.2, 0.3)
approximately(1.0, 1.0 + 1e-6)
approximately(1.0, 1.0 + 1e-6, 1e-5)
Easing
30 zero-dependency easing functions grouped in 10 families (Sine, Quad, Cubic, Quart, Quint, Expo, Circ, Back, Elastic, Bounce), each with In / Out / InOut variants, plus a CSS-compatible cubicBezier sampler. All functions are isomorphic and have no side effects. Every function guarantees f(0) === 0 and f(1) === 1.
import { Easing } from '@toolcase/base'
import { easeInQuad, easeOutCubic, easeInOutBounce, cubicBezier } from '@toolcase/base'
import type { EasingFn } from '@toolcase/base'
Easing functions
All 30 functions share the same signature:
(t: number) => number
t is the normalised progress in [0, 1]. Families that overshoot (Back, Elastic) may return values slightly outside [0, 1] between the endpoints; the endpoints themselves are always exact.
| Export | Family | Behaviour |
|---|
easeInSine / easeOutSine / easeInOutSine | Sine | Smooth sinusoidal acceleration |
easeInQuad / easeOutQuad / easeInOutQuad | Quad | t² — gentle curve |
easeInCubic / easeOutCubic / easeInOutCubic | Cubic | t³ — moderate curve |
easeInQuart / easeOutQuart / easeInOutQuart | Quart | t⁴ — steep curve |
easeInQuint / easeOutQuint / easeInOutQuint | Quint | t⁵ — very steep curve |
easeInExpo / easeOutExpo / easeInOutExpo | Expo | 2^(10t) — exponential |
easeInCirc / easeOutCirc / easeInOutCirc | Circ | Quarter-circle arc |
easeInBack / easeOutBack / easeInOutBack | Back | Overshoots and returns |
easeInElastic / easeOutElastic / easeInOutElastic | Elastic | Spring oscillation |
easeInBounce / easeOutBounce / easeInOutBounce | Bounce | Bouncing-ball effect |
import { easeInCubic, easeOutBounce, Easing } from '@toolcase/base'
easeInCubic(0)
easeInCubic(0.5)
easeInCubic(1)
easeOutBounce(0)
easeOutBounce(0.5)
easeOutBounce(1)
Easing.easeInElastic(0.8)
Easing.easeInOutBack(0.5)
Apply to any interpolation:
import { lerp, easeInOutCubic } from '@toolcase/base'
function animate(start: number, end: number, rawT: number): number {
return lerp(start, end, easeInOutCubic(rawT))
}
cubicBezier
CSS-compatible cubic-bezier curve sampler. Takes two inner control points (x1, y1) and (x2, y2) in [0, 1] and returns an EasingFn that maps t → y using Newton-Raphson + bisection to solve the parametric curve.
cubicBezier(x1: number, y1: number, x2: number, y2: number): EasingFn
Control points must satisfy 0 <= x1, x2 <= 1 for the mapping to be monotone (equivalent to the CSS cubic-bezier() constraint). y1 and y2 may be outside [0, 1] to allow overshoot.
import { cubicBezier } from '@toolcase/base'
const ease = cubicBezier(0.25, 0.1, 0.25, 1.0)
const easeIn = cubicBezier(0.42, 0, 1.0, 1.0)
const easeOut = cubicBezier(0, 0, 0.58, 1.0)
const easeInOut = cubicBezier(0.42, 0, 0.58, 1.0)
ease(0)
ease(0.5)
ease(1)
const spring = cubicBezier(0.175, 0.885, 0.32, 1.275)
spring(0.8)
Validation
JSONSchema
Schema-driven validator. validate() returns a boolean and accumulates issues — it does not throw on a validation failure (it only throws for a structurally invalid schema).
new JSONSchema(schema: Schema, customValidators?: Record<string, ValidationFn>)
The optional 2nd argument registers extra type validators up front (same as calling register() for each).
Schema is a discriminated union on type (not a single flat shape):
type Schema =
| { type: PrimitiveTypeName; required?: boolean }
| { type: 'object'; required?: boolean; flexible?: boolean; properties?: Record<string, Schema> }
| { type: 'array'; required?: boolean; items?: Schema }
| { type: string; required?: boolean; flexible?: boolean; properties?: Record<string, Schema>; items?: Schema }
type ValidationFn = (
propertyName: string | null,
schema: RawSchema,
data: any,
issues: ValidationIssue[]
) => void
interface ValidationIssue { path: string; message: string }
interface ValidationError { issues: ValidationIssue[] }
Note: Schema, ValidationFn, ValidationError, and ValidationIssue are not re-exported from @toolcase/base — import type { Schema } will fail. Annotate inline or pass the literal directly to the constructor.
validate(data): boolean — true when data passes; false otherwise (collecting issues). Throws only when the schema itself is malformed.
getLatestError(): ValidationError | null — the issues from the last validate() call (null when the last call passed).
register(type, validationFn) — add a custom type. Throws if the type is already registered. The validator signature is (propertyName, schema, data, issues) — push { path, message } onto issues to report a problem.
Built-in types:
- Core:
string, boolean, number, integer, object, array
email — RFC-ish email regex
username — ^[A-Za-z][A-Za-z0-9_-]{2,22}$ (starts with a letter; 3–23 chars total)
password — must contain lower + upper + digit + one of !@#$%, length 8–24
url — https?://...
uuid — RFC 4122 v1–v5
date — ISO date YYYY-MM-DD
datetime — ISO 8601 datetime with timezone (Z or ±HH:MM)
ipv4 — dotted-quad
ipv6 — full or compressed
hex — color hex #RGB, #RGBA, #RRGGBB, #RRGGBBAA
slug — lowercase alphanumerics joined by single dashes
semver — semantic version (MAJOR.MINOR.PATCH + optional pre-release/build)
base64 — RFC 4648 base64 (length multiple of 4)
Object semantics:
flexible: false (default) ⇒ unknown properties throw.
- Children defined with
required: false may be omitted.
const schema = new JSONSchema({
type: 'object',
properties: {
name: { type: 'string', required: true },
age: { type: 'number', required: false },
tags: { type: 'array', items: { type: 'string' } }
}
})
if (!schema.validate({ name: 'a', tags: ['x'] })) {
console.error(schema.getLatestError()?.issues)
}
Other
LSystem
Lindenmayer system for procedural string rewriting.
new LSystem({ axiom: string, rules: Record<string, string> })
state: string (read-only; current sequence — initialized to axiom).
iteration: number (read-only).
iterate(): string — apply rules once, return new state. Symbols absent from rules pass through unchanged.
const ls = new LSystem({ axiom: 'A', rules: { A: 'AB', B: 'A' } })
ls.iterate()
ls.iterate()
Color
Material-design palette + helpers. Exported as a single object.
Static palette (uppercase keys, hex strings): RED PINK PURPLE DEEP_PURPLE INDIGO BLUE LIGHT_BLUE CYAN TEAL GREEN LIGHT_GREEN LIME YELLOW AMBER ORANGE DEEP_ORANGE.
Color.getHex(name): string | null — name is lowercased palette key (e.g. 'red', 'deep_purple'); returns null if unknown.
Color.toNumber(name): number — hex parsed as integer (0 if unknown).
Color.getRandomHex(): string — random palette entry.
The ColorType union also type-accepts 'white', 'brown', 'grey', 'blue_grey', and 'black', but those have no palette entry — getHex resolves them to null and toNumber to 0.
Color.getHex('teal')
Color.toNumber('blue')
HTTP
Grouped under HTTP = { Status, RESTError, RESTResponse }. Lightweight REST primitives — no transport included.
Status
Object of status-code constants (e.g. Status.OK = 200, Status.NOT_FOUND = 404, Status.INTERNAL_SERVER_ERROR = 500). All standard 1xx–5xx codes covered, JSDoc-annotated. Includes typo PARCIAL_CONTENT (= 206) — preserve when matching by code.
RESTError
class RESTError extends Error {
readonly status: number
constructor(status: number, message: string)
toJSON(): { status: 'rejected', cause: string }
}
RESTError.notFound(message?)
RESTError.notImplemented(message?)
RESTError.internalServerError(message?)
RESTResponse
class RESTResponse<T = any> {
readonly status: number
readonly data: T
readonly count: number | undefined
constructor(status: number, data: T, count: number | null = null)
toJSON(): { status: 'OK', count?: number, data: T }
}
return new HTTP.RESTResponse(HTTP.Status.OK, users, users.length)
Packing
Rectangle / sprite-atlas packing. Exported as a single namespace Packing (also as a default-export object). Five 2D bin-packing algorithms behind a uniform Algorithm interface, plus a high-level Packer that wires up trimming, sorting, multi-page planning, rotation, and POT (power-of-two) sizing.
import { Packing } from '@toolcase/base'
Public types (importable from @toolcase/base):
import type {
PackingSize, PackingRect, PackingPlacedRect,
PackingSprite, PackingPreparedSprite, PackingPlacedSprite,
PackingPackedPage, PackingResult, PackingPOTMode,
PackingAlgorithmOptions, PackingAlgorithmKind,
PackingMemoryBudget, PackingSortStrategy, PackingPackerOptions
} from '@toolcase/base'
Packer
End-to-end pipeline: input Sprite[] → optional alpha-trim → sort → multi-page place via the chosen algorithm → rotation fix-up → optional POT page size → PackResult.
new Packing.Packer(options: PackerOptions)
interface PackerOptions {
algorithm: 'max-rects' | 'guillotine' | 'shelf' | 'skyline' | 'binary-tree'
maxWidth: number
maxHeight: number
allowRotation: boolean
padding: number
extrude: number
pot: 'none' | 'page' | 'square'
sort: SortStrategy | 'none'
trim: boolean
alphaThreshold: number
budget: MemoryBudget
}
packer.pack(inputs: Sprite[]): PackResult
interface Sprite { id: string; width: number; height: number; pixels?: PixelGrid }
interface PixelGrid { width: number; height: number; alphaAt(x: number, y: number): number }
interface PackResult {
pages: PackedPage[]
unpacked: PreparedSprite[]
}
interface PlacedSprite extends PreparedSprite {
rect: { x: number; y: number; width: number; height: number }
page: number
}
const packer = new Packing.Packer({
algorithm: 'max-rects',
maxWidth: 1024, maxHeight: 1024,
allowRotation: true,
padding: 2, extrude: 1,
pot: 'square',
sort: 'max-side-desc',
trim: false, alphaThreshold: 0,
budget: { maxPages: 4 }
})
const result = packer.pack([
{ id: 'hero', width: 64, height: 96 },
{ id: 'enemy', width: 32, height: 32 },
{ id: 'panel', width: 256, height: 128 }
])
for (const page of result.pages) {
console.log(page.width, page.height, page.occupancy)
for (const s of page.sprites) console.log(s.id, s.rect, s.rotated)
}
Algorithms
All extend the abstract Algorithm class with the same surface:
abstract class Algorithm {
constructor(options: AlgorithmOptions)
abstract insert(size: Size): PlacedRect | null
abstract reset(): void
abstract occupancy(): number
abstract usedBounds(): Size
}
interface AlgorithmOptions {
maxWidth: number; maxHeight: number
allowRotation: boolean
padding: number; extrude: number
pot: POTMode
}
Concrete classes — pick by trade-off:
| Class | Strategy | Best for |
|---|
Packing.MaxRects | Tracks all maximal free rectangles; heuristic choice per insert | Highest occupancy, slower |
Packing.Guillotine | Splits free space with axis-aligned cuts | Good occupancy, fast |
Packing.Skyline | Bottom-left skyline contour | Fast, ideal for similar-height sprites |
Packing.Shelf | Fixed-height horizontal rows | Fastest, good for uniform sizes |
Packing.BinaryTree | Classic growing binary partition tree | Simple, good for incremental packing |
Use an algorithm directly when you want a single page and full control:
const algo = new Packing.MaxRects({
maxWidth: 512, maxHeight: 512,
allowRotation: false, padding: 1, extrude: 0, pot: 'none'
})
const placed = algo.insert({ width: 64, height: 64 })
algo.occupancy()
algo.usedBounds()
Packing helpers
Packing.MultiPagePlanner(factory, { padding, extrude }, budget?) — orchestrates page-by-page placement across an AlgorithmFactory while honoring MemoryBudget caps. Packer uses this internally; reach for it directly when you want a custom pipeline.
Packing.Trimmer(alphaThreshold = 0) — .trim(sprite) scans sprite.pixels and returns a PreparedSprite with cropped width/height + source-offset metadata. Sprites without pixels pass through unchanged.
Packing.Sorter(strategy) — .sort(prepared) returns a stable-sorted copy by area-desc | max-side-desc | height-desc | width-desc | perimeter-desc.
Packing.Rotator() — .apply(placedSprites) swaps sourceWidth/sourceHeight and source offsets for any sprite where rotated === true. Run after placement to normalize metadata for atlas writers.
Packing.potCeil(value) — round a positive integer up to the next power of two; value <= 1 → 1.
Recipes
End-to-end examples that combine multiple primitives.
Cached + retried HTTP fetch
import { Cache, retry, HTTP } from '@toolcase/base'
const userCache = new Cache(async (id: string) => {
return retry(async () => {
const r = await fetch(`/api/users/${id}`)
if (!r.ok) throw new HTTP.RESTError(r.status, `users/${id} ${r.statusText}`)
return r.json()
}, { retries: 4, minTimeout: 250, factor: 2, maxTimeout: 4000 })
}, 60_000)
const user = await userCache.get('42')
Observable game state with State + EventEmitter
import { State } from '@toolcase/base'
const game = new State<{ score: number, lives: number }>({ score: 0, lives: 3 })
game.on('state.score', s => updateHUD('score', s))
game.on('state.lives', l => l <= 0 && gameOver())
game.set({ score: 100 })
game.set({ lives: 2 })
Throttled work via PriorityQueue + retry
import { PriorityQueue, retry } from '@toolcase/base'
interface Job { id: string, run: () => Promise<void>, weight: number }
const jobs = new PriorityQueue<Job>(j => j.weight, j => j.id)
async function pump() {
const job = jobs.dequeue()
if (job === null) return
await retry(job.run, { retries: 3 })
return pump()
}
jobs.enqueue({ id: 'a', weight: 1, run: () => sendBeacon() })
jobs.enqueue({ id: 'b', weight: 5, run: () => uploadCrash() })
pump()
Distributed counter with VectorClock
import { VectorClock } from '@toolcase/base'
const a = new VectorClock('node-a')
const b = new VectorClock('node-b')
a.increment()
b.update(a)
b.increment()
VectorClock.compare(a, b)
VectorClock.compare(b, a)
LSystem-driven procedural map seed
import { LSystem } from '@toolcase/base'
const ls = new LSystem({
axiom: 'F',
rules: { F: 'F[+F]F[-F]F' }
})
for (let i = 0; i < 4; i++) ls.iterate()
drawTurtle(ls.state)
Build a REST handler with RESTResponse / RESTError
import { HTTP } from '@toolcase/base'
async function handler(req: Request) {
try {
const data = await getData(req)
return Response.json(new HTTP.RESTResponse(HTTP.Status.OK, data, data.length))
} catch (err) {
if (err instanceof HTTP.RESTError) {
return Response.json(err, { status: err.status })
}
const fallback = HTTP.RESTError.internalServerError(err.message)
return Response.json(fallback, { status: fallback.status })
}
}
RESTResponse.toJSON() always emits status: 'OK'; RESTError.toJSON() emits status: 'rejected' plus cause. Pair with JSONSchema on the request side:
import { JSONSchema, HTTP } from '@toolcase/base'
const createUser = new JSONSchema({
type: 'object',
properties: {
email: { type: 'email', required: true },
password: { type: 'password', required: true }
}
})
async function POST(req: Request) {
if (!createUser.validate(await req.json())) {
const issues = createUser.getLatestError()?.issues ?? []
const message = issues.map(i => `${i.path}: ${i.message}`).join('; ')
const e = new HTTP.RESTError(HTTP.Status.BAD_REQUEST, message)
return Response.json(e, { status: e.status })
}
}
Frame budgeting with getNumberInRange + formatByteSize
import { getNumberInRange, formatByteSize } from '@toolcase/base'
const fps = getNumberInRange(query.get('fps') ?? '60', 60, 15, 240)
console.log(`bandwidth budget: ${formatByteSize(fps * 1024)}/sec`)
ObjectPool for bullets / particles
import { ObjectPool } from '@toolcase/base'
class Particle {
x = 0; y = 0; vx = 0; vy = 0; life = 0
update(dt: number) { this.x += this.vx * dt; this.y += this.vy * dt; this.life -= dt }
}
const pool = new ObjectPool(Particle, p => { p.x = p.y = p.vx = p.vy = p.life = 0 })
function spawn(x: number, y: number) {
const p = pool.obtain()
p.x = x; p.y = y; p.vx = Math.random() * 2 - 1; p.vy = -1; p.life = 1
return p
}
function tick(dt: number, list: Particle[]) {
for (const p of list) {
p.update(dt)
if (p.life <= 0) (p as any).release()
}
}
Color palette for tagging
import { Color } from '@toolcase/base'
const TAG_COLORS = ['red', 'blue', 'green', 'amber', 'purple']
function tagColor(name: string): string {
const i = [...name].reduce((a, c) => a + c.charCodeAt(0), 0) % TAG_COLORS.length
return Color.getHex(TAG_COLORS[i]) ?? '#888'
}
Graph traversal with AdjacencyMatrix
import { AdjacencyMatrix } from '@toolcase/base'
const g = new AdjacencyMatrix<number, null>(1, null)
;['A', 'B', 'C', 'D'].forEach(v => g.addVertex(v))
g.addEdge('A', 'B', 2)
g.addEdge('B', 'C', 5)
g.addEdge('A', 'C', 9)
g.addEdge('C', 'D', 1)
function shortestHops(start: string, end: string): string[] | null {
const visited = new Set<string>([start])
const queue: { node: string, path: string[] }[] = [{ node: start, path: [start] }]
while (queue.length > 0) {
const { node, path } = queue.shift()!
if (node === end) return path
for (const next of g.getEdges(node)) {
if (!visited.has(next)) {
visited.add(next)
queue.push({ node: next, path: [...path, next] })
}
}
}
return null
}
shortestHops('A', 'D')
Pathfinding on top of AdjacencyMatrix
import { AdjacencyMatrix, Dijkstra, AStar } from '@toolcase/base'
const g = new AdjacencyMatrix<number, null>(1, null)
;['A', 'B', 'C', 'D'].forEach(v => g.addVertex(v))
g.addEdge('A', 'B', 2)
g.addEdge('B', 'C', 5)
g.addEdge('A', 'C', 9)
g.addEdge('C', 'D', 1)
const cheapest = Dijkstra.find('A', 'D', {
neighbors: (v) => g.getEdges(v),
cost: (from, to) => g.getEdge(from, to) as number
})
cheapest?.path
cheapest?.cost
For grid worlds, swap Dijkstra for AStar and supply a Manhattan / Chebyshev heuristic — fewer nodes expanded for the same path. Use the instance API (new AStar(...) + step()) when you need to spread search across frames or react to VISIT / OPEN events for debug overlays.
Hex helpers — cryptographic ids
import { generateId, bufferToHex, hexToBuffer } from '@toolcase/base'
const sessionId = generateId(32)
const bytes = hexToBuffer(sessionId)
const roundTrip = bufferToHex(bytes) === sessionId
Env-driven config (Node)
Requires the sibling packages @toolcase/node (for env) and @toolcase/logging — neither ships in @toolcase/base.
import { env } from '@toolcase/node'
import { LoggerFactory, ConsoleLogReporter } from '@toolcase/logging'
const factory = new LoggerFactory([new ConsoleLogReporter()])
factory.level = env('LOG_LEVEL', 'info') as any
const port = env('PORT', 3000, 'number')
const debug = env('DEBUG', false, 'boolean')
Random
Seedable pseudo-random number generator (mulberry32). Deterministic: identical seeds produce identical sequences. Zero dependencies, isomorphic.
new Random(seed: number)
next(): number — uniform float in [0, 1). Usable as RandomFn for WeightedRandom.
int(min: number, max: number): number — inclusive integer in [min, max]. Throws if min/max are not integers or min > max.
float(min: number, max: number): number — float in [min, max). Throws if bounds are non-finite or min > max.
bool(p: number = 0.5): boolean — true with probability p. Throws if p is outside [0, 1].
pick<T>(arr: T[]): T — uniformly random element. Throws on empty or non-array.
shuffle<T>(arr: T[]): T[] — Fisher-Yates shuffle; returns a new array without mutating the original.
weighted<T>(entries: Array<{ item: T, weight: number }>): T — weighted pick. Throws if entries is empty, any weight is negative/non-finite, or total weight is zero.
import { Random } from '@toolcase/base'
const rng = new Random(42)
rng.next()
rng.int(1, 6)
rng.float(0, 1)
rng.bool(0.3)
rng.pick(['a', 'b', 'c'])
rng.shuffle([1, 2, 3, 4, 5])
rng.weighted([
{ item: 'common', weight: 7 },
{ item: 'rare', weight: 3 }
])
Inject into WeightedRandom for reproducible loot tables:
import { Random, WeightedRandom } from '@toolcase/base'
const rng = new Random(seed)
const loot = new WeightedRandom(entries, (e) => e.weight, () => rng.next())
loot.pick()
Async
Zero-dependency async toolkit. Exported as the Async namespace.
import { Async } from '@toolcase/base'
Deferred
Promise with externally controlled resolve/reject. Useful for bridging callback-based APIs, coordinating between unrelated code paths, or creating one-shot gates.
new Async.Deferred<T>()
promise: Promise<T> — the underlying promise (readonly).
resolve(value: T): void — settle with a value. Subsequent calls are no-ops (native Promise behaviour).
reject(reason?: unknown): void — settle with a rejection. Subsequent calls are no-ops.
const gate = new Async.Deferred<boolean>()
gate.promise.then(ok => console.log('gate opened:', ok))
gate.resolve(true)
Semaphore
Limits concurrent access. At most permits callers may hold the semaphore at once; excess callers queue and are admitted in FIFO order when a slot is released.
new Async.Semaphore(permits: number)
Throws if permits is not a positive integer.
available: number — current free permit count.
acquire(): Promise<void> — waits for a permit.
release(): void — returns a permit (or wakes the next queued waiter).
run<T>(fn: () => T | Promise<T>): Promise<T> — acquire → run → release (releases even on error).
const sem = new Async.Semaphore(3)
const fetchPage = (url: string) => sem.run(() => fetch(url).then(r => r.json()))
const pages = await Promise.all(urls.map(fetchPage))
Mutex
Mutual exclusion — one caller at a time. Thin wrapper around Semaphore(1) with a caller-held release function.
new Async.Mutex()
locked: boolean — true when a caller holds the lock.
acquire(): Promise<() => void> — wait for the lock; the resolved value is the release function. Calling release more than once is a no-op.
run<T>(fn: () => T | Promise<T>): Promise<T> — acquire → run → release (releases even on error).
const mutex = new Async.Mutex()
async function updateCounter() {
const release = await mutex.acquire()
try {
counter++
} finally {
release()
}
}
pLimit
Concurrency gate. Returns a runner function that queues tasks and ensures at most concurrency run in parallel.
Async.pLimit(concurrency: number): <T>(fn: () => T | Promise<T>) => Promise<T>
Throws if concurrency is not a positive integer.
const limit = Async.pLimit(5)
const results = await Promise.all(
urls.map(url => limit(() => fetch(url).then(r => r.json())))
)
withTimeout
Races a promise against a deadline. Rejects with Error('timed out after Nms') if fn has not settled within ms milliseconds.
Async.withTimeout<T>(fn: () => T | Promise<T>, ms: number): Promise<T>
Throws synchronously if ms <= 0.
Composes with retry — pass withTimeout inside the retry callback for per-attempt deadlines:
import { retry, Async } from '@toolcase/base'
const result = await retry(
() => Async.withTimeout(() => fetch('/api/data').then(r => r.json()), 3_000),
{ retries: 4, minTimeout: 500, factor: 2 }
)
sleep
Resolves after ms milliseconds. Throws synchronously if ms < 0.
Async.sleep(ms: number): Promise<void>
await Async.sleep(1_000)
debounce
Returns a debounced version of fn that fires only after ms have elapsed since the last call. The returned function also has a cancel() method to drop any pending invocation.
Async.debounce<T extends (...args: any[]) => void>(fn: T, ms: number): ((...args: Parameters<T>) => void) & { cancel(): void }
Throws if fn is not a function or ms < 0.
const onSearch = Async.debounce((query: string) => {
fetchSuggestions(query)
}, 300)
input.addEventListener('input', e => onSearch(e.currentTarget.value))
onSearch.cancel()
throttle
Returns a throttled version of fn that fires at most once per ms window. Fires on the leading edge and again at the trailing edge if called during the window. The returned function also has a cancel() method to drop the pending trailing call.
Async.throttle<T extends (...args: any[]) => void>(fn: T, ms: number): ((...args: Parameters<T>) => void) & { cancel(): void }
Throws if fn is not a function or ms < 0.
const onScroll = Async.throttle(() => {
updateNavHighlight()
}, 100)
window.addEventListener('scroll', onScroll)
onScroll.cancel()
AsyncQueue
Backpressure-aware producer/consumer channel. Async push/pull, AsyncIterable consumption, optional bounded capacity with backpressure, and close/drain semantics.
new Async.AsyncQueue<T>(capacity?: number)
capacity — maximum number of buffered items. Default Infinity (unbounded). Pass a positive integer to enable backpressure. Constructor throws if capacity is not a positive integer.
Properties:
size: number — current number of buffered items (readonly getter).
closed: boolean — true after close() is called (readonly getter).
Methods:
push(item: T): Promise<void> — add an item. When bounded and full, blocks until a slot is freed. Throws synchronously if item === undefined or if the queue is closed.
pull(): Promise<T> — remove and return the next item. Blocks when the buffer is empty. Rejects with Error('queue is closed') when closed and empty.
close(): void — close the queue. Idempotent. Wakes all pending pull() callers with a rejection and all blocked push() callers with a rejection. Items already buffered can still be consumed after close.
drain(): Promise<void> — resolves when the buffer is empty (all buffered items have been consumed). Resolves immediately if already empty.
[Symbol.asyncIterator](): AsyncIterator<T> — iterate items via for await...of; terminates when the queue is closed and empty.
import { Async } from '@toolcase/base'
const q = new Async.AsyncQueue<string>()
await q.push('hello')
await q.push('world')
q.close()
for await (const msg of q) {
console.log(msg)
}
const bounded = new Async.AsyncQueue<number>(2)
const producer = (async () => {
for (let i = 0; i < 5; i++) {
await bounded.push(i)
}
bounded.close()
})()
const consumer = (async () => {
for await (const n of bounded) {
console.log(n)
}
})()
await Promise.all([producer, consumer])
const channel = new Async.AsyncQueue<string>()
await channel.push('a')
await channel.push('b')
channel.close()
const drainP = channel.drain()
for await (const _ of channel) { }
await drainP
Spatial
2D broad-phase spatial partitioning. Exported as a single namespace Spatial with two structures behind a shared SpatialRect / SpatialPoint contract.
import { Spatial, type SpatialPoint, type SpatialRect } from '@toolcase/base'
Both structures store arbitrary items (T) keyed by their axis-aligned bounding rectangle (SpatialRect). Nearest-neighbour distance is measured from the query point to the nearest edge of an item's bounding rect (distance = 0 if the point is inside the rect).
SpatialHash
Uniform-grid spatial hash. O(1) amortised insert/remove; query cost scales with the number of occupied cells the query rect touches. Best when objects are roughly uniform in size and the cell size is tuned to ~2× the average object diameter.
new Spatial.SpatialHash<T>(cellSize: number)
Constructor throws if cellSize is not a positive finite number.
size: number — read-only item count.
insert(item: T, bounds: SpatialRect): void — no-op if item is already inserted. Throws if item === undefined.
remove(item: T): boolean — returns true if removed; false if not present.
update(item: T, bounds: SpatialRect): void — remove + reinsert (for moving objects).
query(bounds: SpatialRect): T[] — all items whose bounds overlap the query rect; no duplicates.
nearest(point: SpatialPoint, maxDist?: number): T | null — item with the smallest distance to point; respects maxDist (default Infinity).
clear(): this — remove all items. Chainable.
import { Spatial, type SpatialRect } from '@toolcase/base'
type Entity = { id: string }
const hash = new Spatial.SpatialHash<Entity>(64)
const player: Entity = { id: 'player' }
hash.insert(player, { x: 100, y: 100, width: 32, height: 32 })
const enemy: Entity = { id: 'enemy' }
hash.insert(enemy, { x: 300, y: 300, width: 32, height: 32 })
const nearby = hash.query({ x: 80, y: 80, width: 100, height: 100 })
hash.update(player, { x: 280, y: 280, width: 32, height: 32 })
hash.nearest({ x: 0, y: 0 })
Quadtree
Recursive quadrant tree. Insert cost is O(log n) amortised; query prunes by bounding box; nearest-neighbour uses branch-and-bound pruning. Best when object density is uneven (sparse large regions + dense clusters).
new Spatial.Quadtree<T>(bounds: SpatialRect, capacity?: number, maxDepth?: number)
bounds — the world rectangle this tree covers. Items outside it are rejected by insert.
capacity — max items per node before subdivision (default 8).
maxDepth — max tree depth; nodes at max depth store all items regardless of count (default 8).
Constructor throws if bounds is null/undefined, capacity < 1, or maxDepth < 0.
size: number — read-only item count.
insert(item: T, bounds: SpatialRect): boolean — returns true if inserted; false if item was already present or its bounds don't intersect the tree bounds. Throws if item === undefined.
remove(item: T): boolean — returns true if removed; false if not present.
update(item: T, bounds: SpatialRect): void — remove + reinsert.
query(bounds: SpatialRect): T[] — all items whose bounds overlap the query rect.
nearest(point: SpatialPoint, maxDist?: number): T | null — nearest item to point (branch-and-bound). Returns null when empty or nothing within maxDist.
clear(): this — remove all items, preserve root bounds. Chainable.
import { Spatial } from '@toolcase/base'
const world = { x: 0, y: 0, width: 1024, height: 1024 }
const qt = new Spatial.Quadtree<string>(world, 4, 6)
qt.insert('A', { x: 10, y: 10, width: 20, height: 20 })
qt.insert('B', { x: 500, y: 500, width: 20, height: 20 })
qt.insert('C', { x: 15, y: 15, width: 10, height: 10 })
qt.query({ x: 0, y: 0, width: 100, height: 100 })
qt.nearest({ x: 0, y: 0 })
qt.update('A', { x: 600, y: 600, width: 20, height: 20 })
qt.size
qt.clear().size
Math
Vec2
Immutable 2D vector. Every operation returns a new Vec2 — the original is never modified. Zero dependencies, isomorphic.
new Vec2(x: number = 0, y: number = 0)
Static constants:
Vec2.ZERO — Vec2(0, 0) singleton.
Vec2.ONE — Vec2(1, 1) singleton.
Properties:
x: number / y: number — (readonly) components.
length: number — Euclidean length (Math.sqrt(x² + y²)).
lengthSq: number — squared length (avoids sqrt; useful for distance comparisons).
Methods:
add(other: Vec2): Vec2 — component-wise sum.
subtract(other: Vec2): Vec2 — component-wise difference.
scale(factor: number): Vec2 — multiply both components by factor.
dot(other: Vec2): number — dot product (x·ox + y·oy).
normalize(): Vec2 — unit vector in the same direction; returns Vec2.ZERO when length is 0.
lerp(other: Vec2, t: number): Vec2 — linear interpolation; t=0 → this, t=1 → other. Extrapolates outside [0, 1].
rotate(angle: number): Vec2 — rotate by angle radians counter-clockwise.
negate(): Vec2 — flip both components (scale(-1)).
distanceTo(other: Vec2): number — Euclidean distance between two points.
equals(other: Vec2): boolean — exact component equality.
toArray(): [number, number] — [x, y] tuple.
toString(): string — "Vec2(x, y)".
Rect type (also exported from @toolcase/base):
import type { Rect } from '@toolcase/base'
import { Vec2 } from '@toolcase/base'
import type { Rect } from '@toolcase/base'
const a = new Vec2(3, 0)
const b = new Vec2(0, 4)
a.add(b).toString()
a.subtract(b).toString()
a.scale(2).toString()
const c = new Vec2(3, 4)
c.length
c.normalize().toString()
a.dot(b)
a.dot(a)
new Vec2(0, 0).lerp(new Vec2(100, 200), 0.5).toString()
new Vec2(1, 0).rotate(Math.PI / 2).toString()
new Vec2(0, 0).distanceTo(new Vec2(3, 4))
const bounds: Rect = { x: 0, y: 0, width: 100, height: 50 }
const center = new Vec2(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2)
center.toString()
String helpers
Three zero-dependency string utilities for HTML, URL slugs, and display truncation. All are pure functions (no side effects, no state).
import { slugify, truncate, escapeHtml } from '@toolcase/base'
slugify
Convert an arbitrary string into a URL-safe slug: lowercase, ASCII-only, hyphen-separated, no leading/trailing hyphens.
slugify(input: string): string
- Trims leading/trailing whitespace.
- Lowercases.
- Decomposes (NFD) and strips combining diacritical marks (U+0300–U+036F), converting accented letters to their ASCII base.
- Removes any character that is not
a-z, 0-9, space, or -.
- Collapses consecutive whitespace, underscores, and hyphens into a single
-.
- Strips any remaining leading or trailing
-.
slugify('Hello, World!')
slugify(' héllo wörld ')
slugify('café')
slugify('My Post Title 2024')
slugify('hello--world')
slugify('!!!---')
truncate
Shorten a string to maxLength characters, appending a suffix when truncated.
truncate(input: string, maxLength: number, suffix: string = '…'): string
- Returns
input unchanged when input.length <= maxLength.
- Otherwise cuts the input at
maxLength - suffix.length characters and appends suffix.
- When
maxLength < suffix.length, the suffix itself is clipped to maxLength.
truncate('hello world', 8)
truncate('hello world', 8, '...')
truncate('short', 10)
truncate('hi', 1, '…')
escapeHtml
Escape the five HTML special characters (& < > " ') so a string can be safely injected into HTML content or attributes without introducing markup or XSS vectors.
escapeHtml(input: string): string
| Character | Replacement |
|---|
& | & |
< | < |
> | > |
" | " |
' | ' |
escapeHtml('& < > " \'')
escapeHtml('<script>alert("XSS")</script>')
escapeHtml('hello world')
Use before inserting any user-supplied text into innerHTML:
element.innerHTML = `<p>${escapeHtml(userInput)}</p>`
Result / Option
Zero-dependency, isomorphic tagged-union helpers for explicit error handling without throwing.
import { ok, err, some, none } from '@toolcase/base'
import type { Result, Option } from '@toolcase/base'
Result<T, E>
A value that is either a success (Ok<T,E>) or a failure (Err<T,E>). Construct with ok(value) / err(error).
type Result<T, E> = Ok<T, E> | Err<T, E>
ok<T, E = never>(value: T): Ok<T, E>
err<T = never, E = unknown>(error: E): Err<T, E>
Ok<T, E> methods:
| Method | Returns | Notes |
|---|
isOk() | this is Ok<T, E> | always true — narrows the union |
isErr() | this is Err<T, E> | always false |
map<U>(fn) | Ok<U, E> | apply fn to value |
mapErr<F>(fn) | Ok<T, F> | no-op on Ok; preserves value |
andThen<U>(fn) | Result<U, E> | chain a fallible operation |
flatMap<U>(fn) | Result<U, E> | alias for andThen |
unwrap() | T | return value; never throws |
unwrapErr() | never | throws Error('called unwrapErr on an Ok value') |
unwrapOr(default) | T | returns value, ignores default |
Err<T, E> methods:
| Method | Returns | Notes |
|---|
isOk() | this is Ok<T, E> | always false |
isErr() | this is Err<T, E> | always true — narrows the union |
map<U>(fn) | Err<U, E> | no-op on Err; preserves error |
mapErr<F>(fn) | Err<T, F> | apply fn to error |
andThen<U>(fn) | Err<U, E> | no-op on Err; short-circuits chain |
flatMap<U>(fn) | Err<U, E> | alias for andThen |
unwrap() | never | throws Error('called unwrap on an Err value') |
unwrapErr() | E | return error; never throws |
unwrapOr(default) | T | returns default value |
import { ok, err } from '@toolcase/base'
import type { Result } from '@toolcase/base'
function divide(a: number, b: number): Result<number, string> {
return b === 0 ? err('division by zero') : ok(a / b)
}
const r = divide(10, 2)
if (r.isOk()) {
console.log(r.value)
}
ok<number, string>(100)
.andThen(n => divide(n, 4))
.andThen(n => divide(n, 0))
.map(n => n * 100)
.unwrapOr(-1)
divide(1, 0)
.mapErr(msg => new Error(msg))
.unwrapErr()
Option<T>
A value that is either present (Some<T>) or absent (None). Construct with some(value) / none().
type Option<T> = Some<T> | None
some<T>(value: T): Some<T>
none(): None
Some<T> methods:
| Method | Returns | Notes |
|---|
isSome() | this is Some<T> | always true — narrows the union |
isNone() | this is None | always false |
map<U>(fn) | Some<U> | apply fn to value |
andThen<U>(fn) | Option<U> | chain a fallible lookup |
flatMap<U>(fn) | Option<U> | alias for andThen |
unwrap() | T | return value; never throws |
unwrapOr(default) | T | returns value, ignores default |
None methods:
| Method | Returns | Notes |
|---|
isSome() | this is Some<never> | always false |
isNone() | this is None | always true — narrows the union |
map<U>(fn) | None | no-op |
andThen<U>(fn) | None | no-op; short-circuits chain |
flatMap<U>(fn) | None | alias for andThen |
unwrap() | never | throws Error('called unwrap on a None value') |
unwrapOr<T>(default) | T | returns default value |
none() returns a singleton — every call returns the same None instance.
import { some, none } from '@toolcase/base'
import type { Option } from '@toolcase/base'
const users: Record<number, string> = { 1: 'Alice', 2: 'Bob' }
function findUser(id: number): Option<string> {
return id in users ? some(users[id]) : none()
}
const user = findUser(1)
if (user.isSome()) {
console.log(user.value)
}
findUser(2)
.map(name => name.toUpperCase())
.andThen(name => name.length > 2
? some(`Hello ${name}`)
: none())
.unwrapOr('n/a')
findUser(99)
.map(name => name.toUpperCase())
.unwrapOr('unknown')
TokenBucket
Token-bucket rate limiter. The bucket starts full at capacity. Each tryRemove/take call drains tokens; between calls the bucket refills at refillRate tokens per unit of time (where the unit matches the clock injected via now). Zero dependencies, isomorphic.
new TokenBucket(capacity: number, refillRate: number, now?: () => number)
capacity: number — (readonly) maximum number of tokens the bucket can hold. The bucket starts full.
refillRate: number — (readonly) tokens added per unit of time returned by now. With the default Date.now() clock, this is tokens per millisecond — so for 10 req/s use 10 / 1000.
now — optional clock function. Defaults to () => Date.now(). Inject a deterministic clock in tests.
Properties:
tokens: number — current token count computed as of now(), capped at capacity. Side-effect free (does not mutate state).
Methods:
tryRemove(n?: number): boolean — refill, then try to consume n tokens (default 1). Returns true and deducts from the bucket if sufficient tokens are available; false otherwise. Throws if n <= 0.
take(n?: number): boolean — alias for tryRemove(n).
import { TokenBucket } from '@toolcase/base'
const bucket = new TokenBucket(10, 1)
bucket.tryRemove(3)
bucket.tryRemove(8)
bucket.take()
console.log(bucket.tokens)
Inject a deterministic clock for testing:
let t = 0
const bucket = new TokenBucket(5, 1, () => t)
bucket.tryRemove(5)