sortableId()
Time-ordered, prefixed IDs — a fixed-width timestamp plus a random tail, so IDs sort chronologically as plain strings. The idea behind ULID and UUIDv7, with prefID's prefix and type.
Usage
sortableId works just like id() — pass a prefix, get back a typed `${prefix}_${string}`. The difference is the body: it starts with an encoded timestamp, so newer IDs always sort after older ones.
import { sortableId, getTimestamp } from "prefid";
sortableId("evt"); // => "evt_00VQ5a1k0lBjgjfx6pwYy6WkY"
sortableId("evt"); // => "evt_00VQ5a1mgkWGzAvv93g1bC3yR" ← later, sorts after
// Read back the millisecond timestamp baked into the id:
getTimestamp("evt_00VQ5a1k0lBjgjfx6pwYy6WkY"); // => 1721600000000const { sortableId, getTimestamp } = require("prefid");
sortableId("evt"); // => "evt_00VQ5a1k0lBjgjfx6pwYy6WkY"
sortableId("evt"); // => "evt_00VQ5a1mgkWGzAvv93g1bC3yR" ← later, sorts after
// Read back the millisecond timestamp baked into the id:
getTimestamp("evt_00VQ5a1k0lBjgjfx6pwYy6WkY"); // => 1721600000000Why sortable IDs?
- Database-friendly — time-ordered keys keep inserts local in B-tree indexes, avoiding the page fragmentation that random UUIDs cause.
- Natural cursors — “everything after this ID” is a chronological range query, no separate timestamp column required.
- Coordination-free — every process generates ordered IDs on its own; there is no central sequence or lock.
Sorting
Because the timestamp is fixed-width and encoded with an ascending alphabet, lexicographic order equals chronological order. No decoding is needed to sort:
const ids = [sortableId("row"), sortableId("row"), sortableId("row")];
// A plain string sort is also a chronological sort — no parsing needed.
[...ids].sort(); // same order they were created inMonotonic ordering
By default the generator is monotonic: IDs created within the same millisecond — or when the system clock steps backwards — are still strictly increasing. Instead of drawing a fresh random tail, prefID increments the previous one; if that tail is ever exhausted within a millisecond, it spills into the next. Pass monotonic: false for a stateless generator that is only ordered at millisecond granularity.
Reading the timestamp
Three helpers decode the millisecond timestamp baked into a sortable ID. Pass the same alphabet, separator, and timestampSize the ID was generated with (the defaults match sortableId).
getTimestamp(id)— returns the millisecond timestamp, orundefinedwhen the value isn't a well-formed sortable ID. Reach for this when an invalid value is an expected, handled case — you branch on the result.getTimestampOrThrow(id)— the strict variant: returns anumberand throws aTypeErroron a malformed value. Reach for this when a bad ID is a programmer error you want surfaced loudly instead of silently becomingundefined.getDate(id)— a convenience that returns aDate(orundefined) instead of a raw number.
import { getTimestamp, getTimestampOrThrow, getDate } from "prefid";
const id = sortableId("evt");
// Safe: the ms timestamp, or undefined if the value is malformed.
getTimestamp(id); // => 1721600000000
getTimestamp("not-an-id"); // => undefined
// Strict: same value, but throws a TypeError instead of undefined.
getTimestampOrThrow(id); // => 1721600000000
// Convenience: decode straight to a Date (or undefined).
getDate(id); // => Date(2024-07-21T…)const { getTimestamp, getTimestampOrThrow, getDate } = require("prefid");
const id = sortableId("evt");
// Safe: the ms timestamp, or undefined if the value is malformed.
getTimestamp(id); // => 1721600000000
getTimestamp("not-an-id"); // => undefined
// Strict: same value, but throws a TypeError instead of undefined.
getTimestampOrThrow(id); // => 1721600000000
// Convenience: decode straight to a Date (or undefined).
getDate(id); // => Date(2024-07-21T…)type DecodeOptions = {
separator?: string; // default: "_"
alphabet?: string; // default: base62
timestampSize?: number; // default: 9
};
function getTimestamp(id: string, options?: DecodeOptions): number | undefined;
function getTimestampOrThrow(id: string, options?: DecodeOptions): number;
function getDate(id: string, options?: DecodeOptions): Date | undefined;Configuring
Use createSortableId() to build a generator with fixed options, then reuse it:
import { createSortableId } from "prefid";
// A generator with your own settings:
const newId = createSortableId({
randomSize: 20, // more entropy in the random tail
separator: "-", // "evt-…"
});
newId("evt"); // => "evt-00VQ5a1k0lBjgjfx6pwYy6WkYq2mT"const { createSortableId } = require("prefid");
// A generator with your own settings:
const newId = createSortableId({
randomSize: 20, // more entropy in the random tail
separator: "-", // "evt-…"
});
newId("evt"); // => "evt-00VQ5a1k0lBjgjfx6pwYy6WkYq2mT"Signature
function createSortableId(options?: {
separator?: string; // default: "_"
alphabet?: string; // default: base62 (must be ascending)
randomSize?: number; // default: 16
timestampSize?: number; // default: fits 2^48-1 ms (9 base62 chars)
monotonic?: boolean; // default: true
now?: () => number; // default: Date.now
}): <P extends string>(prefix: P) => `${P}_${string}`
// A ready-made generator with the defaults:
const sortableId: <P extends string>(prefix: P) => `${P}_${string}`Options
| Option | Type | Default | Description |
|---|---|---|---|
| separator | string | "_" | Text between the prefix and the body. |
| alphabet | string | base62 | Characters for the timestamp and random tail. Must be in strictly ascending code-point order so sorting works. |
| randomSize | number | 16 | Random characters after the timestamp (1–4096). |
| timestampSize | number | 9 | Width of the encoded timestamp. Defaults to the smallest width that holds any time up to the year 10889. |
| monotonic | boolean | true | Guarantee strictly increasing IDs within a process. |
| now | () => number | Date.now | Clock source, for testing or custom epochs. |
Notes
- A sortable
alphabetmust be in strictly ascending code-point order (the default base62 already is) — otherwise a string sort would not match time order, socreateSortableIdthrows aRangeError. - The random tail uses the same cryptographic RNG as
id(), so IDs are unguessable as well as ordered. isId()andgetPrefix()work on sortable IDs unchanged — the format is stillprefix_body.getTimestamp,getTimestampOrThrow, andgetDatedecode the leading timestamp field; they can't verify that a value was actually produced bysortableId(any ID with a long-enough body decodes to some time). Call them on values you already know are sortable — there is deliberately noisSortableId, because the format carries no marker that would make such a check reliable.- For case-insensitive, unambiguous ids (ULID-style), pass the exported
BASE32_CROCKFORDalphabet — it omitsI,L,O,Uand is already ascending, so it stays sortable.