Timestamp codec
The Timestamp codec is the default. It encodes a 48-bit millisecond Unix timestamp followed by 80 random bits — the same byte layout as a ULID, with deliberate divergences.
import { createTimestampId } from "@smonn/ids";
const users = createTimestampId("usr");const id = users.generate(); // "usr_06f80z92d2dbsqqg28t5cy4tqg"Most of the behavior on this page — lenient parsing, error handling, Standard Schema, JSON Schema, deterministic injection, and the duplicate-brand check — is shared by every codec. The other codec pages only describe what differs.
Lenient parsing and canonical form
Section titled “Lenient parsing and canonical form”safeParse accepts mixed case and the Crockford-spec visual aliases
(o → 0, i → 1, l → 1), and always returns the canonical form —
lowercase, aliases resolved. The 26th character is additionally restricted
to [048cgmrw] — the 8 alphabet values whose low 2 bits are zero, satisfying
the 130→128-bit padding constraint for a 16-byte payload. A string whose final
character falls outside that set is invalid: safeParse returns
{ ok: false, error: 'invalid_base32' }; parse throws IdsError
(code: 'invalid_id', cause: 'invalid_base32'); is returns false.
users.safeParse("usr_06f80z92d2dbsqqg28t5cy4tqg"); // canonicalusers.safeParse("USR_06F80Z92D2DBSQQG28T5CY4TQG"); // uppercaseusers.safeParse("usr_o6f8oz92d2dbsqqg28t5cy4tqg"); // o aliased to 0 (i, l alias to 1)// → { ok: true, id: "usr_06f80z92d2dbsqqg28t5cy4tqg" } for all threeEquality checks on canonical strings work as expected. For untrusted input,
branch on the ParseError union — it is exhaustive at compile time:
const r = users.safeParse(input);
if (!r.ok) { switch (r.error) { case "not_string": return 400; // wasn't a string at all case "invalid_prefix": return 404; // wrong kind of ID (or not an ID) case "invalid_base32": return 400; // prefix matched but payload is malformed }}
const userId = r.id; // Id<"usr">, canonicalis(value) is the strict counterpart — true only for already-canonical
strings. parse(value) is the throwing version of safeParse.
Structured errors
Section titled “Structured errors”parse(), the ORM adapter read paths, and the codec constructors throw
IdsError on failure — a single class with a stable code field. Use
isIdsError() rather than instanceof, so it survives multiple copies of the
package loaded in one process (the ESM + CJS dual-package hazard):
import { isIdsError } from "@smonn/ids";
try { users.parse(rawInput);} catch (err) { if (isIdsError(err)) { switch (err.code) { case "invalid_id": // parse failed; err.cause is the ParseError string return 400; case "invalid_brand": // bad codec construction — fix the brand string throw err; } } throw err;}IdsErrorCode is a stable contract (the message is not). invalid_id
carries the originating ParseError string on cause. The full code list is in
the API reference.
Sort and date-stamp using just the ID
Section titled “Sort and date-stamp using just the ID”The first 6 bytes of the payload are a big-endian millisecond Unix timestamp, so
ORDER BY id sorts by creation time without a separate created_at column.
users.extractTimestamp(id); // DateFor time-range queries, minIdForTime(date) and maxIdForTime(date) build
synthetic IDs at the tight lower and upper bounds of a given millisecond — same
timestamp bytes, random portion all 0x00 (min) or all 0xFF (max):
const start = new Date("2026-01-01T00:00:00Z");const end = new Date("2026-02-01T00:00:00Z");
sql`SELECT * FROM users WHERE id BETWEEN ${users.minIdForTime(start)} AND ${users.maxIdForTime(end)}`;To mint a real ID at a timestamp you choose rather than at now, use
generateAt(date) — the one-liner for backfills:
const id = users.generateAt(new Date("2024-03-15T12:00:00Z")); // Id<"usr">users.extractTimestamp(id); // → 2024-03-15T12:00:00.000Z
// Migrating from UUIDv7 / ULID / Snowflake:const ids = oldRows.map((r) => users.generateAt(extractTime(r)));All three validate the date and throw IdsError with code: "invalid_timestamp"
on invalid input:
- negative timestamp —
date.getTime() < 0 - non-integer timestamp —
date.getTime()is Infinity or a float (e.g.1.5) - timestamp exceeds 48-bit range —
date.getTime() >= 2 ** 48 Invalid Date—date.getTime()isNaN
Use isIdsError from @smonn/ids to catch it — instanceof Error alone matches
but does not discriminate the code:
import { isIdsError } from "@smonn/ids";
try { users.generateAt(date);} catch (err) { if (isIdsError(err) && err.code === "invalid_timestamp") { // date is invalid — negative, non-integer, out of range, or Invalid Date } throw err;}Deterministic tests
Section titled “Deterministic tests”Inject a fixed clock and a no-op RNG for snapshot-friendly output. Both fields
are optional and default to Date.now and a crypto.randomUUID-backed RNG:
const users = createTimestampId("usr", { now: () => new Date("2026-01-01T00:00:00Z").getTime(), rng: (target) => {}, // leave target as zero-filled});
users.generate(); // deterministic outputSee the Testing guide for the full pattern across every codec, including the keyed codecs and property-based testing.
Catch a double-registered brand
Section titled “Catch a double-registered brand”The intended pattern is one codec per brand per process, constructed at module
init. In development (NODE_ENV !== "production"), constructing a second codec
for the same brand emits a one-shot console.warn — it usually means a bundling
or import bug. Opt out where re-creating is intentional:
const users = createTimestampId("usr", { allowDuplicateBrand: true });The check is a heuristic: two physical copies of the package each keep their own registry, so it catches re-imports of one module copy, not duplicate copies of the module itself.
Standard Schema
Section titled “Standard Schema”Each codec implements Standard Schema v1, so it
slots into any validator-aware library (Zod, Valibot, ArkType, tRPC, Hono)
without rewriting z.string().refine(usr.is) boilerplate:
import { type } from "arktype";
const Body = type({ userId: users });
const r = Body({ userId: "USR_06F80Z92D2DBSQQG28T5CY4TQG" });// → { userId: "usr_06f80z92d2dbsqqg28t5cy4tqg" } typed as Id<"usr">validate is synchronous, wraps safeParse, and returns the canonical
Id<Brand> on success. Each ParseError maps to a distinct message
(expected string, expected prefix 'usr_', invalid base32 payload).
JSON Schema
Section titled “JSON Schema”users.toJsonSchema();// {// type: "string",// pattern: "^usr_[0-9a-hjkmnp-tv-z]{25}[048cgmrw]$",// description: "Branded ID for 'usr'",// example: "usr_00000000000000000000000000",// }Drop the result straight into an OpenAPI components.schemas entry. The
pattern describes the canonical form only — it matches generate() output
and is(), but rejects the uppercase and aliases that safeParse() tolerates
(see ADR-0003).
example is a deterministic structural placeholder (prefix + "0".repeat(26)) — stable across calls and consistent with the other codec families.
Native uuid column storage
Section titled “Native uuid column storage”Every codec exposes three methods for converting between a branded Id<Brand>
and an RFC 9562 UUID string. They
reinterpret the 16-byte payload verbatim as 128 bits — a lossless
round-trip that lets you persist an ID into a native uuid column and read it
back as a branded ID. This is useful when migrating off UUID primary keys while
keeping the existing column types and indexes.
const users = createTimestampId("usr");const id = users.generate(); // "usr_06f80z92d2dbsqqg28t5cy4tqg"
const uuid = users.toUUID(id); // "019e807d-2268-9abc-def0-123456789abc"users.fromUUID(uuid); // back to Id<"usr"> — same 16 bytestoUUID(id)— Takes a trustedId<Brand>, returns the payload as a canonical lowercase-hyphenated UUIDstring. Total — cannot fail. The brand is shed; the output is a plainstring, not a branded type.fromUUID(value)— Takes an untrustedstring, returnsId<Brand>. ThrowsIdsError(code: "invalid_id") with the originatingParseErroroncause("invalid_uuid", or"not_string"for untyped JavaScript callers) on malformed input.safeFromUUID(value)— Takesunknown, returnsParseResult<Brand>({ ok: true, id }or{ ok: false, error }, whereerroris"not_string"or"invalid_uuid"). Never throws.
fromUUID and safeFromUUID accept the case-insensitive 8-4-4-4-12
hyphenated form only — braces, the urn:uuid: prefix, and hyphenless 32-char
forms are rejected (see the invalid_uuid ParseError).
Not for hiding creation time
Section titled “Not for hiding creation time”The Timestamp codec exposes the creation time by design — that’s what makes
ORDER BY id work. Anyone with one ID at a known creation time can compute the
epoch offset; a custom epoch wouldn’t help and isn’t supported. To hide creation
time per-ID, use the Opaque Timestamp codec.