Prefixed, sortable public ids. A UUIDv7 in the database, a fixed-width base58 string with a type prefix everywhere else. The two forms encode the same 16 bytes and convert in either direction without loss.
0192f8a1-7c3e-7b9a-8c1d-4e5f6a7b8c9d ⟷ org_1CGtMb233ezidDvSwDLNBn
No dependencies. Runs wherever crypto.getRandomValues exists: Node 20+, Bun,
Deno, Cloudflare Workers, browsers.
npm install id758import { createIdRegistry } from "id758";
export const ids = createIdRegistry({
org: "org",
invoice: "inv",
user: "usr",
});
const uuid = ids.invoice.decode("inv_1CGtMb233ezidDvSwDLNBn"); // → "0192f8a1-…"
const id = ids.invoice.encode(uuid); // → "inv_1CGtMb233ezidDvSwDLNBn"
ids.invoice.mint(); // → "inv_…" with a fresh UUIDv7 inside
ids.invoice.is("org_…"); // false
ids.invoice.decodeOrNull("org_…"); // null
ids.invoice.decode("org_…"); // throws "not a inv_ id"Each helper's ids are typed Id<"invoice">, a branded string, so TypeScript
refuses to pass an invoice id where a user id is expected. Decoded values are
typed Uuid, a different brand, so a still-encoded id can't reach a uuid slot
either. Both are plain strings at runtime.
import { uuidv7, encodeId, decodeId, mintId } from "id758";
uuidv7(); // "0192f8a1-7c3e-7b9a-8c1d-4e5f6a7b8c9d"
encodeId(uuid, "org"); // "org_1CGtMb233ezidDvSwDLNBn"
decodeId("org_1CGtMb233ezidDvSwDLNBn"); // "0192f8a1-…" (any prefix accepted)
mintId("org"); // encodeId(uuidv7(), "org")A prefixed id names its own entity, so it can be resolved without context, for example a polymorphic foreign key or an event payload:
import { entityOf, decodeAnyId } from "id758";
entityOf(ids, "inv_1CGtMb233ezidDvSwDLNBn"); // "invoice"
entityOf(ids, "0192f8a1-…"); // null — a bare uuid names nothing
decodeAnyId(ids, "inv_…"); // the uuid
decodeAnyId(ids, "0192f8a1-…"); // passed through unchangeddecodeAnyId passes unrecognised values through unchanged, so it can sit in
front of a query that may receive either form. When you know the entity, use
that helper's decode, which rejects the wrong prefix.
Keep the column a native uuid. Database defaults and ::uuid casts keep
working, and UUIDv7's timestamp prefix keeps inserts clustered. Encode when
sending an id to a client; decode when one comes in, at the request boundary.
Everything below that works with raw uuids.
Storing the prefix in every row would duplicate what the table already says.
The codec is also shipped as plain plpgsql — id758.sql in the package, or
import { ID758_SQL } from "id758/sql" — so the database speaks both forms
and nothing has to convert in application code before touching it directly:
select * from invoices where id = id758_decode('inv_1CGtMb233ezidDvSwDLNBn');
select id758_encode('inv', id) as id, total from invoices;| Function | Description |
|---|---|
id758_encode(prefix text, id uuid) → text |
encodeId. |
id758_decode(id text) → uuid |
decodeId: validates the shape, any prefix. |
id758_decode(id text, prefix text) → uuid |
Also requires that prefix, like a registry helper's decode. |
id758_prefix(id text) → text |
prefixOf: the prefix, or NULL. |
The functions are IMMUTABLE STRICT PARALLEL SAFE: NULL in gives NULL
out, a literal argument is folded at plan time so the ordinary primary-key
index serves where id = id758_decode($1), and they can back an expression
index or a generated column. No extensions; Postgres 14 or later. The script is
create or replace and safe to re-run, so apply it once in a migration
(ID758_SQL_STATEMENTS has it one statement per entry for runners that want
that) or on the fly:
npx id758 sql | psql "$DATABASE_URL"npx id758 mint inv # inv_1CGtMb233ezidDvSwDLNBn
npx id758 decode inv_1CGtMb233ezidDvSwDLNBn # 0192f8a1-7c3e-7b9a-8c1d-4e5f6a7b8c9d
npx id758 encode inv 0192f8a1-7c3e-7b9a-8c1d-4e5f6a7b8c9dinv_…tells a reader what the id is for (compare Stripe'scus_…/sub_…).- The body is left-padded to 22 characters and the alphabet is in ascending order, so lexical order equals numeric order, which for UUIDv7 equals creation order. Ids minted in the same millisecond are in random order relative to each other.
- Base58 uses the Bitcoin alphabet: no
0/O, noI/l, no punctuation or padding. Ids are URL-safe and double-click selectable. - The database only ever sees standard UUIDs. Removing the library means deleting the encode/decode calls at the boundary; no data migration.
| Export | Description |
|---|---|
uuidv7() |
Mint an RFC 9562 UUIDv7 (48-bit ms timestamp, random tail). Returns Uuid. |
encodeId(uuid, prefix) |
Hyphenated uuid → prefix_<22 base58 chars>. A no-op on a prefix_ id; throws on an invalid prefix. |
decodeId(id) |
Inverse of encodeId. Validates the shape, not the prefix. Throws. |
mintId(prefix) |
encodeId(uuidv7(), prefix). |
prefixOf(id) |
The prefix of a well-formed id, else null. |
createIdRegistry({ entity: prefix, … }) |
One IdHelper per entity: mint, encode, decode, decodeOrNull, is, prefix. |
entityOf(registry, value) |
Which registry entity a value belongs to, else null. |
decodeAnyId(registry, value) |
Decode any registered entity's id; pass unknown values through. |
isUuid(value) / asUuid(value) |
Narrow / assert a string to Uuid (any RFC 9562 version, any case). |
encodeBase58(bytes) / decodeBase58(body) |
The raw 16-byte ⟷ 22-char codec. |
uuidToBytes(uuid) / bytesToUuid(bytes) |
Hyphenated uuid ⟷ 16 bytes. |
ALPHABET |
123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz |
| Types | Id<E>, Uuid, IdHelper<E>, IdRegistry<K> |
ID758_SQL / ID758_SQL_STATEMENTS (from id758/sql) |
The Postgres functions, as one script / one statement per entry. |
Prefixes must match [A-Za-z0-9]+; the underscore is the separator, so it
cannot appear in a prefix.
decodeBase58 rejects bodies above 2¹²⁸ − 1 (base58 has a little more room
than 128 bits) rather than truncating, so no two distinct strings decode to the
same uuid.
The encoding is: take the 16 bytes of the UUID, interpret them as one
big-endian unsigned integer, write it in base 58 with the alphabet above, and
left-pad with 1 (the zero digit) to 22 characters. An implementation is
correct when it reproduces these vectors:
| Bytes (hex) | Body |
|---|---|
00000000000000000000000000000000 |
1111111111111111111111 |
00000000000000000000000000000001 |
1111111111111111111112 |
000000000000000000000000000000ff |
111111111111111111115Q |
0192f8a17c3e7b9a8c1d4e5f6a7b8c9d |
1CGtMb233ezidDvSwDLNBn |
ffffffffffffffffffffffffffffffff |
YcVfxkQb6JRzqk5kF2tNLv |
The same table lives in src/index.test.ts.
The Postgres port in id758.sql is checked against it in
src/sql.test.ts.
MIT © Ingram Technologies