Multi-dimensional addresses for objects.
A single object often has more than one identity at the same time: a path inside a tenant, a path inside an environment, a version on a release axis, a parent pointer for orchestration. Address collapses those identities into one ordered, serializable container so they travel together and survive a round-trip through string form.
user:/u/42@2;org:/o/acme;env:/prod@1
That string is one ObjectAddress. The first segment is the anchor — immutable, the thing the object "really is." The rest are secondary coordinates, each in its own dimension (user, org, env). Anything that knows about a dimension can ask the address for its path, version, and parent payload in that dimension; anything that doesn't can ignore them and just use the anchor.
Zero dependencies. Pure TypeScript. ESM.
npm install @console-one/addressPlain string IDs work fine until one of these starts to hurt:
- The same logical entity needs to be located in several namespaces at once (tenant + environment + version axis), and you keep building parallel maps to keep them in sync.
- You need a stable, parseable wire form for that compound identity (logging, caching keys, message envelopes).
- Some callers care about the version, others don't — and you want both reads to be one-liners against the same value.
- You want to "fork" an entity into a sibling location: same anchor, different env or version, without manually stitching the new identity together.
If a string is enough, use a string. If you've reached for a Map<TenantId, Map<EnvId, ...>>, you're in this package's territory.
import { ObjectAddressImpl, Address, Addressed } from '@console-one/address';
// Construct directly
const a = new ObjectAddressImpl({ dim: 'user', path: '/u/42', version: 3 });
a.set({ dim: 'org', path: '/o/acme' });
a.set({ dim: 'env', path: '/prod', order: 1 }); // place env between user and org
a.toString(); // "user:/u/42@3;env:/prod;org:/o/acme"
a.getPath('org'); // "/o/acme"
a.isVersioned('user'); // true
a.dimensions(); // ["user", "env", "org"]
// Round-trip through a string
const b = ObjectAddressImpl.parse('user:/u/42@3;org:/o/acme');
a.equals(b, 'user'); // true (same dim+path+version on user)
// Tag a domain object with its address
type User = Addressed<'User', { username: string }>;
const ada: User = Addressed.create('User', { username: 'ada' }, [
{ dim: 'user', path: '/u/ada', version: 1, parent: { id: 'u1' } },
{ dim: 'org', path: '/o/eng' },
]);
ada.address.toString(); // "user:/u/ada@1;org:/o/eng"
ada.address.getParent('user'); // { id: "u1" }<dim>:<path>[@<version>][;<dim>:<path>[@<version>]]*
- The first segment is the anchor and cannot be removed or reordered.
- Segments are separated by
;. Order is preserved across parse / stringify. @<version>is optional. The version slot accepts any number; non-numeric tails parse as "no version."- Empty input yields an addressless container (an anchor is materialised on first read at the default dimension).
Address.parse('user:/a/b@3') and Address.stringify(addr) are exact inverses on well-formed input.
Address— one{type, dim, path, version?, parent?, order?}record.Address.create,Address.parse,Address.stringify,Address.clone,Address.compare,Address.describes.AddressLike— the loose "shape" used as input.AddressLike.parse(str)does the textual decode without building a fullAddress.AddressLike.describes(x)is the structural type guard.AddressBuilder— fluent builder for anAddress(.addDimension,.addPath,.addVersion,.addParent,.addOrder,.build).AddressPath/RelativeAddressPath/AbsoluteAddressPath— string aliases with type guards. They're nominally distinct but structurally identical (both are strings); the split exists to document author intent at call sites.
ObjectAddress/ObjectAddressImpl— the multi-dimensional container. The interface is read-side; the impl carries the mutators (set,remove,fork,setParentPayload,[Symbol.iterator]).ObjectAddressImpl.parse(s)/.fromJSON(...)— accept a stringified form, a singleAddress, an array of addresses, or a{dim: {path,...}}map.AddressSet— thetoJSON()shape:{type: 'addressset', addresses: {[dim]: AddressLike}}. Useful as a wire form when you want a JSON object instead of the colon/semicolon string.dim(name)/dims(...names)— small helpers for building dimension selectors used byfork().
RelativeAddressSet— wire-form list of relative coordinates.RelativeObjectAddress/RelativeObjectAddressImpl— a builder-style container of relative coordinates, indexable by dimension, used as input tofork.
Addressable—{...item, location | address}mixin for "give this domain object an identity later."Addressable.getAddress(item)resolves either the eagerly-parsed address or a lazily-generated one.Addressed—{type, address, ...child}for "give this domain object an identity right now."Addressed.create(type, child, refs)builds the full record.
fork() produces a new ObjectAddress derived from an existing one. The argument selects which dimensions to carry over and (optionally) overrides their paths or versions:
const base = ObjectAddressImpl.parse('user:/u/42@2;org:/o/acme;env:/prod@1');
base.fork(); // deep copy of all three dims
base.fork([dim('env'), dim('user')]); // subset, in the order given
base.fork('/elsewhere'); // single new default-dim address
base.fork({ env: { path: '/staging' } }); // override env's path on the fork
base.fork(RelativeObjectAddressImpl.create({ dim: 'env', path: 'ignored' }));
// pick dims via a relative address containerOverride paths win; missing fields fall back to the corresponding dim on the base.
src/
├── address.ts # Address, AddressLike, AddressBuilder
├── addresspath.ts # path string aliases + guards
├── addressset.ts # AddressSet — JSON wire form
├── addressable.ts # Addressable mixin + getAddress helper
├── addressed.ts # Addressed<Type, Child> tag
├── dimension.ts # DefaultDimension, dim()
├── objectaddress.ts # ObjectAddress, ObjectAddressImpl, fork, dims()
├── relativeaddressset.ts # RelativeAddressLike, RelativeAddressSet
├── relativeobjectaddress.ts # RelativeObjectAddress, RelativeObjectAddressImpl
├── util.ts # uuid, splitFirst, splitLast (vendored)
├── smoke.ts # end-to-end test
└── index.ts # public surface
- The anchor is immutable. Setting a new dimension with
order: 0throws; callingremove(anchorDim)returnsfalse. Fork the address and re-anchor instead. setis upsert. Callingset({dim, path})on an existing dim merges field-by-field — only fields you provide overwrite the prior value. Calling it on a new dim requirespath(we don't materialise pathless secondary refs).get(dim)falls back to the anchor. If the requested dim isn't present,getreturns the anchor instead ofundefined. This keeps the read path branchless on the caller side; if you need a presence check, usehas(dim).fork(termArray)honours overrides. A term'spathandversionwin over what's on the base. Dimensions you don't list aren't carried over.getRoots()returns defensive clones. Mutating the returned array's entries does not affect the source.Address.parseis total. Malformed-but-string input yields a best-effortAddress; non-numeric versions becomeundefined. There is no throwing parser.- The string form is order-stable.
parsethenstringifyis a no-op on a well-formed input.
npm run smoke exercises the package's full surface against node:assert. Anchor immutability, parse/stringify round-trips, fork variants, defensive copies, address-set JSON shape, the Addressable and Addressed mixins, and every code path on RelativeObjectAddressImpl. The build aborts publish if any assertion fails.
RelativeAddressPathandAbsoluteAddressPathare bothstringand structurally indistinguishable — the type guards return the same answer. The split is documentation, not enforcement.forkaccepts a{[dim]: {path, version, ...}}map, but the typed signature treats values asOmit<RelativeAddressLike, 'dim'>. In practice you'll oftenas anythe value object until the input type is narrowed.- Versions are bare
numbers. There is no opinion about monotonicity, semver, or vector clocks; comparison is===.
MIT.