TypeScript implementation of Universal Entity IDs (UEIDs) — timestamp-ordered, model-tagged, machine-efficient UUIDs.
  • TypeScript 77.4%
  • JavaScript 22.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Kalmiya b59728db0a
Some checks failed
CI / node 20 · typecheck · lint · format · test · build (push) Failing after 0s
CI / node 22 · typecheck · lint · format · test · build (push) Failing after 0s
CI / node 24 · typecheck · lint · format · test · build (push) Failing after 0s
CI / node 26 · typecheck · lint · format · test · build (push) Failing after 0s
feat: full UEID spec conformance + strict ver/var validation
Implements the expanded UEID Specification v1 (full bit layout
+ encoding + parsing, not just the model field). The spec
repo's references in the README were unqualified, and a future
reader would reasonably expect ueid-spec to define the whole
format, not just the model field algorithm.

Spec conformance:
- conformance/roundtrip-vectors.json: 8 vectors pinning every
  field of a UEID (unix_ts_ms, ver, model, count, var, random,
  hex, dashed, undashed). Mirrors the canonical set in ueid-spec.
- scripts/generate-roundtrip-vectors.js: regenerate the file
  when the bit layout or encoding changes.
- scripts/check-conformance.mjs: now verifies BOTH the model-
  hash vectors and the round-trip vectors.
- test/conformance.test.ts: 10 vitest cases covering round-trip,
  layout invariants, fixed-bit validation, dashed/undashed
  forms.

Bug fix from the spec work:
- The hardcoded test-vector string used by the Ruby gem and
  inherited here ('019c2c83-8104-83fa-8417-5fb7e28a5464')
  encoded model=65, count=7 in its bits, not the documented
  model=63 (String), count=10. Corrected everywhere to
  '019c2c83-8104-83fa-8000-1fb7e28a5464'.
- test/ueid.test.ts updated with the matching byte constant
  and the non-byte-aligned round-trip case now exercises a
  value with valid ver/var bits.

Spec compliance:
- UEID.fromString now rejects strings whose ver field is not
  0b1000 or whose var field is not 0b10, per SPECIFICATION.md
  §2.2 and §2.5. Previous behaviour silently accepted any
  32-hex-char string regardless of reserved bits. Throws
  UEIDFormatError on mismatch.

Docs:
- README: retitle 'Model ID Specification' section to 'UEID
  Specification' to match the spec repo's scope.
- CHANGELOG: document the additions.

86/86 tests pass; all 66 model-hash and 8 round-trip conformance
vectors verify against the canonical spec.
2026-07-21 12:53:20 -07:00
.github/workflows feat: add UEID Model ID Specification v1 conformance suite 2026-07-21 11:48:09 -07:00
.husky build: add husky, prettier, eslint, lint-staged 2026-07-21 10:27:38 -07:00
conformance feat: full UEID spec conformance + strict ver/var validation 2026-07-21 12:53:20 -07:00
scripts feat: full UEID spec conformance + strict ver/var validation 2026-07-21 12:53:20 -07:00
src feat: full UEID spec conformance + strict ver/var validation 2026-07-21 12:53:20 -07:00
test feat: full UEID spec conformance + strict ver/var validation 2026-07-21 12:53:20 -07:00
.editorconfig build: add husky, prettier, eslint, lint-staged 2026-07-21 10:27:38 -07:00
.gitignore build: add husky, prettier, eslint, lint-staged 2026-07-21 10:27:38 -07:00
.lintstagedrc.json build: add husky, prettier, eslint, lint-staged 2026-07-21 10:27:38 -07:00
.prettierignore build: add husky, prettier, eslint, lint-staged 2026-07-21 10:27:38 -07:00
.prettierrc.json build: add husky, prettier, eslint, lint-staged 2026-07-21 10:27:38 -07:00
CHANGELOG.md feat: full UEID spec conformance + strict ver/var validation 2026-07-21 12:53:20 -07:00
eslint.config.mjs build: add husky, prettier, eslint, lint-staged 2026-07-21 10:27:38 -07:00
LICENSE feat: initial TypeScript port of ueid-ruby 2026-07-21 10:14:31 -07:00
package.json build: bump toolchain to latest, expand CI to Node 20/22/24 matrix 2026-07-21 11:07:18 -07:00
pnpm-lock.yaml build: bump toolchain to latest, expand CI to Node 20/22/24 matrix 2026-07-21 11:07:18 -07:00
README.md feat: full UEID spec conformance + strict ver/var validation 2026-07-21 12:53:20 -07:00
tsconfig.build.json feat: initial TypeScript port of ueid-ruby 2026-07-21 10:14:31 -07:00
tsconfig.json feat: add UEID Model ID Specification v1 conformance suite 2026-07-21 11:48:09 -07:00
vitest.config.ts feat: initial TypeScript port of ueid-ruby 2026-07-21 10:14:31 -07:00

ueid

TypeScript implementation of Universal Entity IDs (UEIDs) — universal, machine-identifiable record identifiers. Zero runtime dependencies. ESM. Node 20+.

This is a port of ueid-ruby to TypeScript, with the ActiveRecord integration removed. Models are registered explicitly through the Registry API.

The 8-bit model field follows the UEID Specification, Version 1, which both implementations verify against the same shared test-vector set.

What is a UEID?

UEIDs follow the same design principle as UXIDs (human-friendly, type-tagged IDs) but optimise for machine processing and storage efficiency. They are an implementation of UUID Version 8, combining:

  • the timestamp ordering of UUIDv7,
  • a model field identifying the entity type (8 bits),
  • a sequential counter for intra-millisecond uniqueness (4 bits), and
  • a random component for global uniqueness (62 bits).

They can be stored using the native UUID data types of modern databases — PostgreSQL's uuid, for example — which makes them more compact and faster to index than string-based identifiers.

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                          unix_ts_ms                           |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|          unix_ts_ms           |  ver  |     model     | count |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|var|                        random                             |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                            random                             |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

Fields

unix_ts_ms (48 bits)

A 48-bit big-endian unsigned integer of the Unix epoch timestamp in milliseconds, per the UUID Timestamp Considerations. Identical to the timestamp field of UUIDv7.

ver (4 bits)

The 4-bit UUIDv8 version per the UUID Version Field. Always 0b1000 (= 8).

model (8 bits)

An 8-bit unsigned integer identifying the entity type. The hash is computed by FNV-1a (32-bit) over the UTF-8 bytes of the class name, then folded to 8 bits.

count (4 bits)

A 4-bit per-process counter that increments with each UEID generated within the same millisecond and model. The counter wraps at 16; after that, the 62 random bits are still strong enough to keep two UEIDs distinct with overwhelming probability.

var (2 bits)

The 2-bit UUID variant per the UUID Variant Field. Always 0b10 (= RFC 4122bis).

random (62 bits)

62 bits of cryptographically random data sourced from crypto.randomBytes in Node.

Limitations

  • Model identifiers are only unique within a single application. Different applications may use the same model identifier for different entity types.
  • The 8-bit model field is a 256-bucket hash. Two different identifiers have a 50% chance of colliding once you register roughly 20 of them (birthday paradox). Registry.hashForClass throws on collision by default; rename one of the conflicting identifiers or call Registry.hashForClassAllowingCollision to take the Ruby gem's silent-overwrite behaviour.
  • Up to 256 unique entity types are theoretically possible per application, but in practice you should expect collisions well before that ceiling.
  • UEIDs can guarantee a maximum of 16 unique IDs per model per millisecond. Applications requiring higher throughput should implement additional mechanisms.
  • Only 62 bits of randomness are provided, compared to 74 in UUIDv7 and 122 in UUIDv4. This is generally sufficient for most applications.

Installation

pnpm add ueid
# or
npm install ueid

Usage

import { generate, toClass, Registry, UEID } from "ueid";

// Register your classes explicitly (one-time, at boot).
// The registry remembers the hash → class mapping so that
// `toClass(ueid)` can recover the original class.
Registry.hashForClass(User);
Registry.hashForClass(Order);

// Generate a UEID for a class
const id: string = generate(User);
// → "019f85ab-9608-8681-8100-2dfe0f56b1ac"

// ...or for a string identifier
const orderId: string = generate("Order");

// Round-trip back to the class
const klass = toClass(id); // → User

// Parse manually if you want the bytes
const parsed = UEID.fromString(id);
console.log(parsed.bytes); // bigint, 128 bits

The Registry accepts either a string or anything with a .name property:

Registry.hashForClass("User"); // by name
Registry.hashForClass(User); // by class
Registry.hashForClass({ name: "Order" }); // by tagged object

Database storage

UEIDs are 128 bits and fit in a standard UUID column. For example, with Drizzle:

import { uuid } from "drizzle-orm/pg-core";

export const users = pgTable("users", {
  id: uuid("id")
    .primaryKey()
    .$defaultFn(() => generate(users)),
  // ...
});

You can assign UEIDs from a beforeInsert-style hook in your ORM of choice, or generate them application-side and pass the string in.

API

generate(klass: ClassLike): string

Mint a UEID for the given class identifier and return its canonical 36-character UUID form. The class is registered with the Registry if it is not already.

generate(User); // string
generate("User"); // string

toClass(ueid: string): ClassLike

Parse a UEID string and return the class identifier that minted it. Throws UEIDLookupError if no class is registered for the embedded model hash.

toClass("019f85ab-9608-8681-8100-2dfe0f56b1ac"); // → User

Registry.hashForClass(klass: ClassLike): number

Compute (or return the cached) 8-bit hash for the class identifier. Idempotent — re-registering the same identifier returns the cached hash without touching any state.

Collisions. The 8-bit model field is a 256-bucket hash; the birthday-paradox threshold is around 20–25 identifiers. If a different identifier hashes to a slot already occupied by another registration, hashForClass throws UEIDLookupError naming both colliding identifiers and the shared hash. Re-registering the same identifier is a no-op and never triggers the collision check.

If you want the Ruby gem's silent-overwrite behaviour (the later registration wins, displacing whatever was there), call hashForClassAllowingCollision instead.

Registry.hashForClassAllowingCollision(klass: ClassLike): number

Same as hashForClass but silently overwrites any prior binding for the computed hash. This is the Ruby gem's default behaviour, preserved here for callers who have measured a collision and want to keep it. Most callers should prefer the strict hashForClass and rename the colliding identifier instead.

Registry.computeModelHash(name: string): number

Pure function: compute the 8-bit hash for a string identifier without consulting or mutating the registry. Useful for probing collisions before registering, or for tests that need to assert hash values without side effects.

Registry.classForHash(hash: number): ClassLike

Look up the class identifier associated with the given hash. Throws if no class is registered for that hash.

Registry.clear(): void

Forget every registered mapping. Intended for tests.

Registry.size(): number

Number of distinct identifiers currently registered.

UEID.fromString(input: string): UEID

Parse a UEID from either the dashed 8-4-4-4-12 form or the un-dashed 32-hex-char form. Throws UEIDFormatError on malformed input.

new UEID(bytes: bigint | number)

Construct a UEID from its raw 128-bit value.

ueid.bytes: bigint

Raw 128-bit value, bigint.

ueid.toString(): string

Canonical 8-4-4-4-12 lowercase hex form with dashes (36 chars).

ueid.toClass(): ClassLike

Same as Registry.classForHash(extractModelBits(ueid)).

ueid.equals(other: unknown): boolean

Structural equality on the underlying bigint.

Errors

  • UEIDError — base class for every UEID-related failure.
  • UEIDFormatError — malformed input to UEID.fromString or new UEID.
  • UEIDLookupError — model hash is not registered, or invalid class identifier.

Bit-layout constants

For callers that want to inspect or manipulate UEID bytes directly:

import {
  COUNTER_MASK,
  COUNTER_SHIFT,
  MODEL_SHIFT,
  RANDOM_MASK,
  TIMESTAMP_MASK,
  TIMESTAMP_SHIFT,
  UUID_VARIANT,
  UUID_VERSION,
  VARIANT_SHIFT,
  VERSION_SHIFT,
} from "ueid";

Each constant is exported as a bigint (masks / shifts) or number (mask nibbles). The bit positions match the layout diagram above and the Ruby reference gem.

Differences from the Ruby gem

  • The ActiveRecord auto-registration path is removed. You call Registry.hashForClass(klass) explicitly.
  • The byte value is held as a JavaScript bigint instead of a Ruby Integer. All bitwise operations on bigint are sign-aware, but every UEID is non-negative so the semantics are identical.
  • Randomness comes from crypto.randomBytes; the timestamp comes from Date.now().
  • A ClassLike accepts either a string identifier or any value with a non-empty name: string property, making it convenient to test without instantiating real domain classes.
  • The to_bytes Ruby alias is not provided — the public bytes field is sufficient.

Development

Requirements

  • Node 20 or later (CI matrix: 20, 22, 24, 26 — Iron, Jod, Krypton, current)
  • pnpm 11 or later

Setup

git clone https://git.anteater-wall.ts.net/kalmiya/ueid-ts.git
cd ueid-ts
pnpm install

Scripts

Script What it does
pnpm typecheck tsc -p tsconfig.json (typecheck src + test, no emit)
pnpm test vitest run — one-shot test execution
pnpm test:watch vitest — watch mode
pnpm build tsc -p tsconfig.build.json — emit dist/
pnpm lint eslint over src/ and test/
pnpm lint:fix eslint --fix
pnpm format prettier --write over the source, tests, and config
pnpm format:check prettier --check (used in CI)
pnpm prepare husky — installs the git hooks (runs on pnpm install)

Verifying everything locally

pnpm format:check && pnpm lint && pnpm typecheck && pnpm test && pnpm build

This is the same gate the GitHub Actions CI workflow runs on every push and PR.

Pre-commit hook

Husky runs lint-staged on every commit, which formats and lints staged files. Hooks live in .husky/ and the staged-file globs are in package.json under lint-staged.

CI

The GitHub Actions workflow at .github/workflows/ci.yml runs on push and PR to trunk. It tests against a matrix of Node versions — Iron (20), Jod (22), Krypton (24), and the current release line (26) — so every supported Node release line is exercised on every change. Each matrix entry installs dependencies with the frozen lockfile, then runs format check, lint, typecheck, test, conformance check (UEID Model ID Spec v1), build, and a smoke import of the built artifact.

UEID Specification

This library implements the UEID Specification, Version 1. The full algorithm contract — bit layout, encoding, parsing rules, the FNV-1a-32 + iterative xor-shift fold that produces the model field, the conformance checklist, and the round-trip vectors that pin every field of a UEID — is in SPECIFICATION.md in the spec repository. Test vectors live at conformance/vectors.json (model-hash) and conformance/roundtrip-vectors.json (full-bit-layout), both mirrored in this repository.

The conformance check (scripts/check-conformance.mjs) is the canonical reference implementation in TypeScript; the same vectors are checked by the Ruby gem (scripts/check-conformance.rb in the ueid-ruby repo). Both run in their respective CI pipelines.

License

MIT — see LICENSE.