https://github.com/smonn/ids
Public-facing branded IDs for TypeScript apps.
https://github.com/smonn/ids
base32 ids typescript ulid
Last synced: 1 day ago
JSON representation
Public-facing branded IDs for TypeScript apps.
- Host: GitHub
- URL: https://github.com/smonn/ids
- Owner: smonn
- License: mit
- Created: 2026-06-01T00:03:29.000Z (28 days ago)
- Default Branch: main
- Last Pushed: 2026-06-20T04:22:41.000Z (8 days ago)
- Last Synced: 2026-06-20T04:23:05.078Z (8 days ago)
- Topics: base32, ids, typescript, ulid
- Language: TypeScript
- Homepage:
- Size: 312 KB
- Stars: 1
- Watchers: 0
- Forks: 0
- Open Issues: 8
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- Contributing: CONTRIBUTING.md
- License: LICENSE
- Security: SECURITY.md
- Agents: AGENTS.md
Awesome Lists containing this project
README
# @smonn/ids
Public-facing branded IDs for TypeScript apps. Type-safe, sortable, and codec-pluggable.
๐ **Full documentation & interactive playground: [ids.smonn.se](https://ids.smonn.se)**
```bash
pnpm add @smonn/ids
```
Each ID looks like `usr_06f80z92d2dbsqqg28t5cy4tqg`: a three-letter brand, an underscore, then 26 Crockford base32 characters of payload. The default Timestamp codec encodes a 48-bit millisecond Unix timestamp followed by 80 random bits โ the same byte layout as a [ULID](https://github.com/ulid/spec).
## Quickstart
```ts
import { type Id, createTimestampId } from "@smonn/ids";
const users = createTimestampId("usr");
// Generate โ sortable by creation time via ORDER BY id
const id = users.generate(); // "usr_06f80z92d2dbsqqg28t5cy4tqg"
// Branded: Id<"usr"> and Id<"org"> are not interchangeable
function loadUser(id: Id<"usr">) {
/* ... */
}
// Validate untrusted input โ lenient in, canonical out
const r = users.safeParse("USR_06F80Z92D2DBSQQG28T5CY4TQG");
if (r.ok) {
r.id; // "usr_06f80z92d2dbsqqg28t5cy4tqg" as Id<"usr">
}
```
`safeParse` accepts mixed case and the Crockford visual aliases (`o โ 0`, `i โ 1`, `l โ 1`) and always returns the canonical lowercase form. See the [Timestamp codec guide](https://ids.smonn.se/codecs/timestamp/) for sorting, backfills (`generateAt`), range queries, structured errors, Standard Schema, and JSON Schema.
## Choosing a codec
All six codecs share the same `_<26 chars>` wire shape but make different trade-offs. They are wire-indistinguishable โ `safeParse`, `is`, and `parse` cannot distinguish an Opaque Timestamp ID from a Timestamp ID at runtime. Cross-codec confusion is undetectable by the library; the consumer is responsible for routing a given ID to the correct codec for the brand. Codec choice is therefore a per-brand commitment.
| Codec | Import | Sort direction | Key required | Timestamp extractable |
| --- | --- | --- | --- | --- |
| Timestamp | `@smonn/ids` | Ascending (oldest-first) | No | Always (plaintext) |
| Reverse Timestamp | `@smonn/ids/reverse` | Descending (newest-first) | No | Always (plaintext) |
| Signed Timestamp | `@smonn/ids/signed` | Ascending (oldest-first) | Yes (signing key) | Always (plaintext) |
| Opaque Timestamp | `@smonn/ids/opaque` | None (encrypted) | Yes (key material) | With key only |
| Wrapped key | `@smonn/ids/wrapped` | None | Yes (wrapping key) | N/A โ not timestamp-family |
| Digest | `@smonn/ids/digest` | None | Yes (digest key) | N/A โ not timestamp-family |
The Timestamp codec is the default and ships from the root `@smonn/ids` entry โ it has no `/timestamp` subpath by design. If you try `import ... from "@smonn/ids/timestamp"` you will get a module-resolution error; use `@smonn/ids` directly. Every other codec uses a named subpath (`/reverse`, `/signed`, `/opaque`, `/wrapped`, `/digest`); this asymmetry is intentional and permanent.
- **Newest-first scans** on forward-only KV stores โ [Reverse Timestamp](https://ids.smonn.se/codecs/reverse/)
- **Tamper-evident share links** verified without a DB lookup โ [Signed Timestamp](https://ids.smonn.se/codecs/signed/) (integrity)
- **IDs that must not leak creation time** โ [Opaque Timestamp](https://ids.smonn.se/codecs/opaque/) (confidentiality)
- **A public handle for an internal integer PK** โ [Wrapped key](https://ids.smonn.se/codecs/wrapped/)
- **Idempotency keys, content-addressed records, or stable public pseudonyms** โ [Digest](https://ids.smonn.se/codecs/digest/)
Try them all live in the [playground](https://ids.smonn.se/playground/).
## Integrations
Framework and ORM adapters ship as optional subpath exports (each requires its own peer dependency):
- **HTTP params:** [Hono](https://ids.smonn.se/adapters/hono/), [Express](https://ids.smonn.se/adapters/express/), [Fastify](https://ids.smonn.se/adapters/fastify/) โ `idParam`, `idQuery` middleware; [NestJS](https://ids.smonn.se/adapters/nestjs/) โ `ParseIdPipe`
- **ORM columns:** [Drizzle](https://ids.smonn.se/adapters/drizzle/) โ `idColumn`, `idColumnMysql`, `idColumnSqlite`, `nullableIdColumn`, [Kysely](https://ids.smonn.se/adapters/kysely/) โ `idPlugin` / `idColumn`, `nullableIdColumn`, [MikroORM](https://ids.smonn.se/adapters/mikro-orm/) โ `idType`, `nullableIdType`, `idField`, [Prisma](https://ids.smonn.se/adapters/prisma/) โ `idField`, `nullableIdField`, [TypeORM](https://ids.smonn.se/adapters/typeorm/) โ `idTransformer`, `nullableIdTransformer`
- **GraphQL:** [GraphQL](https://ids.smonn.se/adapters/graphql/) โ `idScalar` custom scalar
- **CLI:** brand-agnostic `inspect` / `generate` / `keygen` โ `npx @smonn/ids --help` ([docs](https://ids.smonn.se/cli/))
Every codec also implements [Standard Schema v1](https://standardschema.dev/), so it slots into Zod, Valibot, ArkType, tRPC, and any validator-aware library.
**Native `uuid` column storage:** an `Id` can be persisted into a native `uuid` column via `codec.toUUID(id)` and read back as a branded ID via `codec.fromUUID(value)` or `codec.safeFromUUID(value)` โ a lossless round-trip useful when migrating off UUID primary keys while keeping existing column types and indexes.
## What this is **not** for
- **Internal surrogate primary keys.** If nobody outside your service sees the ID, the brand prefix and lenient parsing are dead weight. Use a `bigint` sequence.
- **Wire-compatible ULIDs.** The byte layout is ULID-shaped, but the encoding is lowercase and brand-wrapped. Stock ULID parsers will reject these.
- **Spec-valid UUIDv7 output.** `toUUID` produces a **raw, unversioned** UUID โ all 128 payload bits are preserved verbatim (lossless round-trip), which means the version/variant nibble positions hold real data, not `0x7`/`0b10`. It is **not** a spec-valid UUIDv7. Only the Timestamp codec happens to produce a UUID whose leading 48 bits are a real millisecond timestamp; only Timestamp and Reverse Timestamp produce time-sortable UUIDs. Importing a non-time-ordered UUID (e.g. a UUIDv4) into a timestamp-family codec via `fromUUID` yields a structurally valid `Id` with a meaningless timestamp and random sort order โ the same wire-indistinguishable contract that already governs codec variants.
- **Distributed-trace / request-correlation IDs.** Use OpenTelemetry-format IDs.
- **Hiding creation time with the Timestamp codec.** Anyone with one ID at a known creation time can compute the epoch offset. Use the Opaque Timestamp codec to hide creation time per-ID.
## API surface
Exports from the main `@smonn/ids` entry point only. Codec-specific subpath exports (`@smonn/ids/reverse`, `@smonn/ids/opaque`, `@smonn/ids/signed`, `@smonn/ids/wrapped`, `@smonn/ids/digest`) and adapter subpaths are not listed here.
### Types
- `Id` โ Canonical branded ID string for `Brand`; produced by `generate()` and `safeParse()`.
- `ParseError` โ Parse failure reason string returned by `safeParse()` (`"not_string"`, `"invalid_prefix"`, or `"invalid_base32"`) and by `safeFromUUID()` (`"not_string"` or `"invalid_uuid"`).
- `ParseResult` โ Discriminated union returned by `safeParse()`: `{ ok: true; id: Id }` or `{ ok: false; error: ParseError }`.
- `JsonSchema` โ Shape of the object returned by a codec's `toJsonSchema()`.
- `IdsErrorCode` โ String-literal union of the eleven stable error codes carried by `IdsError`.
- `TimestampCodec` โ Interface of a brand-scoped Timestamp codec instance returned by `createTimestampId()`.
- `TimestampOptions` โ Construction options for `createTimestampId()`: `now`, `rng`, and `allowDuplicateBrand`.
- `ValidBrand` โ Compile-time validation that `S` is a well-formed brand (three lowercase `aโz` characters); intersect it with a codec constructor's brand parameter (`brand: Brand & ValidBrand`) to reject malformed brands at the type level.
### Classes
- `IdsError` โ Single error class thrown by caller-reachable failures; carries a stable `code: IdsErrorCode`. Use `isIdsError()` rather than `instanceof` to detect across realms.
### Functions
- `isIdsError(value)` โ Type guard for `IdsError`; uses an internal brand to survive ESM/CJS dual-package duplication where bare `instanceof` fails.
- `createTimestampId(brand, options?)` โ Creates a Timestamp codec for `brand` (three lowercase `aโz` characters).
### Shared codec methods (all variants)
Every codec instance exposes the following UUID interop methods in addition to the codec-specific ones documented on the [full docs site](https://ids.smonn.se):
- `toUUID(id)` โ Takes a trusted `Id`, returns the 16-byte payload reinterpreted as a canonical lowercase-hyphenated UUID `string`. Total โ cannot fail. The brand is shed; the output is a plain `string`, not a branded type.
- `fromUUID(value)` โ Takes an untrusted `string`, returns `Id`. Throws `IdsError` (`code: "invalid_id"`) with a `ParseError` on `cause` (`"invalid_uuid"`, or `"not_string"` for untyped JavaScript callers) on malformed input.
- `safeFromUUID(value)` โ Takes `unknown`, returns `ParseResult` (`{ ok: true; id }` or `{ ok: false; error: ParseError }`). Never throws.
## Links
- **[Documentation](https://ids.smonn.se)** โ full guides, API reference, and playground
- **[SPEC.md](./SPEC.md)** โ descriptive wire-format specification
- **[Design decisions](./docs/adr/)** โ recorded ADRs
- **[CONTEXT.md](./CONTEXT.md)** โ glossary of the project's vocabulary
- **[Contributing](./CONTRIBUTING.md)** ยท **[Security](./SECURITY.md)**
## License
[MIT](./LICENSE)