Utilities
The framework solves a handful of problems for itself that turn up again in the
code you write on top of it: representing “there might be no value” without
null, carrying a failure without throwing, computing something once on first
use, draining a queue that keeps growing, looking a pair up from either end,
naming something unguessably, printing a value on a path where throwing would
replace the error you were reporting, and depending on a package you do not
want to make mandatory.
These are the answers it already uses, exported from the root entry point. None
of them are required to use actor-ts — reach for one when you have the problem
it solves.
import { BidirectionalMap, BidirectionalMultiMap, Lazy, RingBuffer, none, safeStringify, some, tryOf,} from 'actor-ts';Option — a value that might not be there
Section titled “Option — a value that might not be there”Option<T> is Some<T> | None. Prefer it over T | null in domain-API
returns and long-lived fields: the type forces the empty case to be handled at
the point of use rather than remembered.
import { fromNullable, none, some, type Option } from 'actor-ts';
const leader: Option<string> = some('node-a');leader.map((address) => address.toUpperCase()).getOrElse('<none>'); // 'NODE-A'none.getOrElse('<none>'); // '<none>'
fromNullable(process.env.SEED_NODE); // Some | None, from a nullable| Export | What it does |
|---|---|
some(value) / none | Build one. none is a shared instance, not a factory. |
fromNullable(value) | null / undefined → None, anything else → Some. |
fromPredicate(value, pred) | Some when the predicate holds, else None. |
firstSome(...options) | The first Some, or None. |
The API mirrors Scala’s where practical: map, flatMap, filter,
filterNot, fold, getOrElse, orElse, exists, forall, contains,
forEach, toArray, toNullable, isSome, isNone. isSome() and
isNone() are type guards, and both classes work with ts-pattern’s
P.instanceOf(Some).
Nullable is still the right choice in three places, and the framework uses it
there on purpose: hot-path internal fields where the wrapper would show up in a
profile, serialization boundaries that have to round-trip through null, and
optional parameters with ergonomic defaults.
Try — a computation that may have thrown
Section titled “Try — a computation that may have thrown”Try<T> is Success<T> | Failure. It turns a throw into a value you can pass
around, which is what you want when the failure has to travel — into a message,
across an ask, into a state machine.
import { tryOf } from 'actor-ts';
const parsed = tryOf(() => JSON.parse(payload));
parsed .map((body) => body.userId) .recover(() => 'anonymous') .getOrElse('anonymous');tryOf(compute) runs the function and captures whatever it throws;
success(value) and failure(error) build one directly. Beyond the shared
map / flatMap / fold / getOrElse / orElse / filter / forEach,
Try adds recover (map the error to a value), recoverWith (map it to
another Try), get (rethrow), toNullable and toError.
trySequence(tries) turns an array of Try<T> into a Try<T[]> that fails on
the first failure.
Either — two outcomes, neither of them an error
Section titled “Either — two outcomes, neither of them an error”Either<L, R> is Left<L> | Right<R>, right-biased: map and flatMap work
on the right. Reach for it over Try when the failure side is a domain value
you want typed — a validation report, a rejection reason — rather than a thrown
error.
import { left, right, type Either } from 'actor-ts';
function parseAge(raw: string): Either<string, number> { const value = Number(raw); return Number.isInteger(value) && value >= 0 ? right(value) : left(`not an age: ${raw}`);}
parseAge('42').fold((reason) => `rejected: ${reason}`, (age) => `age ${age}`);mapLeft, bimap and swap work the other side; isLeft() and isRight()
are type guards. eitherOf(compute) catches a throw into Left<Error>, and
eitherSequence collects an array the way trySequence does.
Lazy — compute once, on first use
Section titled “Lazy — compute once, on first use”Lazy<T> is Scala’s lazy val: the computation runs on the first get() and
the result is memoised, including a thrown failure, so an expensive or
side-effecting setup happens exactly once no matter how many callers ask.
import { Lazy, lazyImportModule } from 'actor-ts';
const redisLazy = Lazy.of(() => lazyImportModule<typeof import('ioredis')>('ioredis'));
// First call imports; every later one returns the same promise.const redis = await redisLazy.get();Lazy.of(compute) (aliased as lazy) and Lazy.evaluated(value) build one.
get() forces it, peek() returns the value only if it has already been
computed, getSync() unwraps a resolved async cell, and map / flatMap /
forEach compose without forcing. reset() and setOverride(value) exist for
tests — they are the reason a lazily-imported backend can be swapped for a fake
without touching the production path.
RingBuffer — a queue that stays fast when it gets deep
Section titled “RingBuffer — a queue that stays fast when it gets deep”RingBuffer<T> is a first-in-first-out queue whose removal from the front is
O(1). It exists because Array.prototype.shift() is not: it reindexes every
element still queued, so the cost of taking one item grows with the number of
items behind it. That is invisible on a queue of ten and expensive on a queue
of a hundred thousand — which is what an actor’s mailbox becomes when it falls
behind its producers, since the default mailbox has no ceiling.
import { RingBuffer } from 'actor-ts';
const pending = new RingBuffer<string>();pending.push('a');pending.push('b');pending.shift(); // 'a'pending.unshiftAll(['x', 'y']); // both go in front of 'b', in this orderpending.length; // 3pending.drain(); // ['x', 'y', 'b'] — and the ring is emptyFour operations and nothing else. push appends (amortised O(1) — the
backing array doubles, and is not allocated at all until the first push, so an
unused ring costs what an empty [] costs). shift takes the front. drain
empties it into a fresh array and releases the backing store, so a queue that
grew to a million gives the memory back. unshiftAll(items) puts a whole batch
in front in one move, without a spread — the framework uses it to replay a
stash of up to a thousand messages, where unshift(...items) would both
reindex the queue once per message and push the batch onto the call stack as
arguments.
It is not a general-purpose deque, deliberately: every extra operation is
another index-wrapping edge case in a type that sits on the message path of
every actor in the system. Both queues inside a
Mailbox are one, and so is the
ThroughputDispatcher work queue.
BidirectionalMap — a map with a maintained inverse
Section titled “BidirectionalMap — a map with a maintained inverse”BidirectionalMap<K, V> is a Map<K, V> that also answers value → key. It
exists because a reverse index written by hand is two maps that have to be
updated in lockstep, and the failure mode when they drift is silent: a stale
entry keeps answering for a pair that is already gone.
import { BidirectionalMap } from 'actor-ts';
const seats = new BidirectionalMap<string, number>();seats.set('ada', 1);seats.set('grace', 2);
seats.get('ada'); // 1seats.getKey(2); // 'grace' ← the direction a Map cannot answerseats.deleteValue(1); // removes the pair from both sidesIt implements Map<K, V> in full, so it drops into anything that takes one —
new Map(bidirectionalMap), spreading, for…of, forEach.
Beyond Map | What it does |
|---|---|
getKey(value) / hasValue(value) / deleteValue(value) | The reverse direction. |
trySet(key, value) | Binds only if that removes nothing; false otherwise. |
inverse() | The same map read the other way round — a view, not a copy. |
reverseEntries() | [value, key] pairs. |
getOrInsertKey(value, defaultKey) | Like getOrInsert, from the value side; returns a key. |
getOrInsertComputedKey(value, callback) | The same, minting the key on demand. callback runs at most once. |
keySize / valueSize | Distinct keys and distinct values. Both always equal size, because the relation is 1:1 — they exist so this type and BidirectionalMultiMap, where the counts genuinely differ, read alike. |
Both directions are backed by Map, so both compare by SameValueZero:
NaN works as a key and as a value, 0 and -0 are the same, and two
structurally equal objects are two different values. When you need structural
identity, index a derived string — that is what the framework’s own shard does,
keying on ref.path.toString() rather than on the ref.
It can live in persistent state
Section titled “It can live in persistent state”Unlike an ordinary class, a BidirectionalMap survives a journal, snapshot or
durable-state round-trip as a real instance — no
event adapter, no serializer registration,
nothing at the boundary. The tagged JSON tree knows it the way it knows Map
and Set.
type State = { seats: BidirectionalMap<string, number> };
class SeatingPlan extends PersistentActor<Command, Event, State> { initialState(): State { return { seats: new BidirectionalMap() }; } // After a restart, state.seats is a BidirectionalMap again — and // state.seats.getKey(2) works, even though only the forward pairs // were ever written.}Only the forward pairs go to disk; the inverse is rebuilt on decode. See what events and state may contain for the full round-trip table.
A store configured with withSerializer(new CborSerializer()) carries it too,
under CBOR tag 27. That was not always so — until
#1036 the CBOR codec
dropped it, along with Map and Set, without raising anything.
BidirectionalMultiMap — the same for a many-to-many relation
Section titled “BidirectionalMultiMap — the same for a many-to-many relation”BidirectionalMultiMap<L, R> relates many lefts to many rights and answers in
both directions. It exists for the shape a subscription registry has: one
subscriber holds many topics, one topic has many subscribers, and the message
telling you a subscriber is gone carries only the subscriber.
import { BidirectionalMultiMap } from 'actor-ts';
const subscriptions = new BidirectionalMultiMap<string, string>();subscriptions.add('news', 'ada');subscriptions.add('news', 'grace');subscriptions.add('sport', 'ada');
subscriptions.get('news'); // Set { 'ada', 'grace' }subscriptions.getKeys('ada'); // Set { 'news', 'sport' } ← the reverse directionsubscriptions.size; // 3 — pairs, not participantssubscriptions.leftSize; // 2 — distinct topicssubscriptions.rightSize; // 2 — distinct subscribers
subscriptions.deleteRight('ada'); // ada leaves every topic at oncesubscriptions.hasLeft('sport'); // false — sport held only ada, so it is gone tooThat last line is the invariant worth knowing: there is no such thing as an
empty participant. Removing the last partner removes the participant from
both directions. A topic left behind holding an empty subscriber set is
invisible to a pair count, keeps occupying whatever cap bounds your topics, and
would let inverse() hand back something related to nothing.
| Beyond a pair of maps | What it does |
|---|---|
add(left, right) / delete(left, right) | One pair, both directions. add is idempotent. |
get(left) / getKeys(right) | Everything one side is related to. Empty set when absent — never undefined, so you can iterate without a guard. |
deleteLeft(left) / deleteRight(right) | Drop one participant entirely — the Terminated case. |
hasLeft(left) / hasRight(right) | Whether that participant has at least one partner. |
lefts() / rights() | The participants, each listed once. |
size / leftSize / rightSize | Pairs, distinct lefts, distinct rights. All three O(1) — reach for one of the participant counts rather than measuring [...lefts()]. |
inverse() | The relation read the other way round — a view, not a copy. size stays true on both. |
Equality is SameValueZero, as with BidirectionalMap, and for the same
reason: both directions are backed by Map and Set. Two structurally equal
objects are two different participants, so index a derived string when you need
structural identity — which is what the framework’s own call sites do, keying
subscribers on ref.path.toString() rather than on the ref. A Terminated
carries the cell’s own self ref, which need not be the object that
subscribed; the path is the identity both sides agree on.
It can live in persistent state too
Section titled “It can live in persistent state too”Like its 1:1 sibling, it survives a journal, snapshot or durable-state round-trip as a real instance — no adapter, no registration. Only the forward direction is written, as an adjacency list; the inverse is rebuilt on decode, so the two halves cannot come back disagreeing. The same CBOR caveat applies.
Random strings and identifiers
Section titled “Random strings and identifiers”| Export | What it does |
|---|---|
randomString(length, options?, exists?) | length characters from the enabled classes. Default: alphanumeric. exists may take the second slot when you pass no options. |
randomHex(length, exists?) | length lowercase hex characters — for wire formats that mandate [0-9a-f]. |
randomId(length, exists?) | length characters for something you have to name yourself. |
randomUuid(exists?) | A version-4 UUID — for an identifier that must not collide with one minted elsewhere. |
RandomStringOptions | { lowerCase?, upperCase?, digits? } — each defaults to true. |
ExistsPredicate | (candidate: string) => boolean — the optional collision check all four take. true means “taken, draw again”. |
The first three draw from globalThis.crypto, correct the modulo bias that a
plain byte % alphabet.length introduces, and return exactly the length you
asked for — never fewer. randomUuid delegates to
globalThis.crypto.randomUUID().
import { randomId, randomString, randomUuid } from 'actor-ts';
const sessionId = randomId(12); // '9f3c1ab0e7d2'const coupon = randomString(8, { lowerCase: false, digits: true }); // 'K7Q2XP4M'const persistenceId = randomUuid(); // 'f81d4fae-7dec-41d0-a765-00a0c91e6bf6'randomId is the one to reach for when naming an actor. An actor name ends up
in an actor path, and a path is an address: on the cluster wire, anything that
can render one can send to it. A counter makes that address guessable — knowing
one hands you the next — which is why the framework names anonymous actors and
ask reply refs from here rather than from $1, $2, $3.
randomHex exists separately because some alphabets are not a style choice.
W3C trace-context mandates [0-9a-f]{32} and [0-9a-f]{16} for a
traceparent header, and a peer rejects anything else.
randomUuid sits at the other end of the entropy scale from randomId, and the
split between the two is worth getting right. randomId(12) is ~48 bits: unguessable among
the names one process holds live at once, which is the only uniqueness an actor
name has to carry. randomUuid() is 122 random bits, which is what it takes for
an identifier to stay distinct from one minted in another process, on another
machine, years later, with nothing coordinating them — a PersistenceId for a
new aggregate, a correlation id crossing a broker, a key another system will
read.
Drawing until you get a free one
Section titled “Drawing until you get a free one”All four take an optional predicate as their last argument and draw again while
it answers true:
import { randomUuid } from 'actor-ts';
const userId = randomUuid((id) => state.users.has(id));which is the loop that used to sit at the call site:
let userId: UserId;do { userId = randomUuid();} while (state.users.has(userId));The callback is that while condition. That is why it is named exists and
why true means “draw again” — an accept-predicate would read as the negation
of the loop it replaces, and would put a ! on every Map- or Set-backed
call site. randomString takes it in the second slot when you pass no options
and in the third when you do: randomString(8, exists),
randomString(8, { digits: false }, exists).
It reads, it does not write. Nothing records the accepted value for you, so
whatever exists consults is still yours to update.
Stringifying a value that already went wrong
Section titled “Stringifying a value that already went wrong”safeStringify(value, maxLength?) renders any value as a string and never
throws.
import { Actor, safeStringify } from 'actor-ts';
class Worker extends Actor<unknown> { onReceive(message: unknown) { this.log.warn(`unhandled message: ${safeStringify(message)}`); }}JSON.stringify throws on a circular structure and on a BigInt. Using it to
build a log line or an error message therefore risks replacing the problem you
were reporting with a different one — thrown from inside the reporting code,
where a caller is least likely to be handling it. An onReceive that logs a
message it did not expect is exactly that situation: the message is
unrecognised, so nothing is known about its shape.
Cycles collapse to [Circular], BigInt renders with an n suffix, functions
and symbols are named, and the result is capped (8 KiB by default) rather than
allowed to grow into a multi-megabyte string that blocks the event loop while
being built.
Logging a URL without logging the password
Section titled “Logging a URL without logging the password”A connection URL is the one string in a configuration that routinely carries a
secret inline — amqp://user:pass@host/vhost, mongodb+srv://user:pass@cluster
— and the places that print one are the places least likely to be reviewed for
disclosure: a rejection message, a reconnect warning, a frame a remote peer
sent. Two renderings, for two different jobs.
import { redactUrlCredentials, redactedUrlLabel } from 'actor-ts';
redactUrlCredentials('amqp://user:pass@rabbit:5672/vhost');// → 'amqp://***@rabbit:5672/vhost'
redactedUrlLabel('wss://user:pass@example.com/ws/orders?token=abc');// → 'wss://example.com/ws/orders'redactUrlCredentials(value) masks the userinfo and changes nothing else —
no normalisation, no trailing slash, no lowercased host — so an operator still
recognises the value they typed. It is a strict no-op on anything without a
scheme://…@ authority, which is what makes it safe to apply unconditionally to
a value that only might be a URL (':memory:', 'file:local.db',
'not a url' all come back verbatim). This is what the framework’s own
option validation puts into every OptionsError
that rejects a URL.
redactedUrlLabel(value) goes further and reduces a URL to a stable identity:
scheme, host, port and path, with the userinfo and the query string dropped —
a WebSocket endpoint is commonly authenticated with a ?token=…. The path is
kept deliberately, since it is what tells two connections to the same host
apart. Use it wherever a remote peer influences how often a line is written —
which is to say wherever the URL would otherwise be replayed into the log at
someone else’s discretion. The WebsocketClientActor uses it for its
oversize-frame warning. That line is no longer written per frame: the breach
closes the connection, so a peer gets one line per dial. The redaction stands
regardless, because a peer that provokes reconnects still decides how many
dials there are.
BrokerActor is the other consumer, and its case is not a log at all:
redactedEndpointLabel() is what reaches BrokerConnected,
BrokerDisconnected, BrokerReconnectFailed and BrokerReconnectAttempt,
which are published on the system-wide EventStream. That audience is wider
than a log file — subscribe has no authorization concept — and the events
fire on the happy path, once per connect and once per reconnect attempt. It
also shows the fallback doing real work: a joined server list is not a
parseable URL, so it takes the scan path and comes back as
nats://***@a:4222,nats://***@b:4222 rather than being flattened to one
host.
Optional peer dependencies
Section titled “Optional peer dependencies”lazyImportModule(name, options?) imports a module dynamically and, when it is
missing, throws an error that names the package and the command that installs
it.
import { Lazy, lazyImportModule } from 'actor-ts';
const redisLazy = Lazy.of(() => lazyImportModule<typeof import('ioredis')>('ioredis', { context: 'MyCacheActor' }),);
// MyCacheActor requires the 'ioredis' package. Install it with: npm install ioredis// Original error: Cannot find module 'ioredis'| Export | What it does |
|---|---|
lazyImportModule<T>(name, options?) | import(name), or throw a “missing peer dependency” error. |
LazyImportOptions | { context?, installHint? } — who needed it, and how to install it. |
This is the pattern the framework’s own brokers, caches and persistence
backends are built on, and the reason every one of them fails the same way: the
module must not be a hard dependency, so the import has to be dynamic, so the
default failure mode is a bare Cannot find module that says nothing about
which package is missing or what to do about it. Pairing it with Lazy — also
exported from the root entry point — means the import happens once, on first
use, rather than at construction.
Options builders and validators
Section titled “Options builders and validators”OptionsBuilder and OptionsValidator are the two base classes behind every
XOptions family in the framework — MqttOptions, SqliteJournalOptions,
ActorSystemOptions and the rest. You do not need them to use those options;
you need them to write your own in the same shape.
import { OptionsBuilder, OptionsValidator } from 'actor-ts';
type MyOptionsType = { readonly port?: number; readonly host?: string };
class MyOptionsBuilder extends OptionsBuilder<MyOptionsType> { withPort(port: number): this { return this.set('port', port); } withHost(host: string): this { return this.set('host', host); }}
class MyOptionsValidator extends OptionsValidator<MyOptionsType> { constructor() { super('MyOptions'); }
protected rules(_settings: Partial<MyOptionsType>): void { this.port('port'); // no-op when unset; throws OptionsError otherwise this.nonEmptyString('host'); }}A builder is its settings: the protected set writes each field as an own
enumerable property, so an instance is structurally a bag of the fields you set
and a plain object is interchangeable with it.
The check helpers (port, positiveNumber, positiveInt, nonNegativeInt,
numberInRange, oneOf, nonEmptyString, nonEmptyArray, url, and fail
for bespoke rules) take only the field name — it is typo-checked against
your options type, and a helper on a field of the wrong type is a compile
error. Every one is a no-op when the field is undefined, so an unset optional
always passes and required-ness stays wherever you enforce it. A rejected value
throws OptionsError, exported alongside so you can catch it specifically —
and when the rejected value is a URL, url reports it with its credentials
masked (Logging a URL without logging the password, above).
Validation runs once, at consume time, on the merged settings — so the builder, a plain object and HOCON are all covered, and cross-field rules see the final values. See configuration for how the layers resolve.
Where to next
Section titled “Where to next”- Logging — where
safeStringifyusually ends up. - Spawning actors — naming, and what an actor path exposes.
- Persistent actors — what state may contain, and how it round-trips.
- Configuration — how options, HOCON and defaults layer.
- Installation — which of the framework’s own dependencies are optional.
