TypeID — the default
Stripe-style prefix_ulid IDs. Sortable, branded, URL-friendly, doubleclick-selectable, ~30 char. Default for every user-facing entity.
TypeID is the framework's default ID scheme — the format Stripe popularized: a short table-tag prefix, an underscore, then a 26-character Crockford-base32 ULID body.
user_01j5xkqyz8x3n4m9pvabcd
└─┘ └──────────────────────┘
type ulid body (48-bit ms timestamp + 80-bit random)Every id() call without an explicit scheme: argument produces TypeID.
Spec
TypeID is a community spec maintained at typeid.io. The framework implements it via the typeid-js library — Jetify's official TypeScript implementation. Shape:
- Prefix: must match
^[a-z][a-z0-9_]*$— lowercase ASCII that STARTS with a letter (a leading digit or underscore is rejected bytypeid-js). Up to 63 chars (practical limit: keep it short). - Separator: single underscore
_. - Body: 26 characters in Crockford-base32 (no
i,l,o,uto avoid visual ambiguity). Represents a 128-bit ULID. - Body composition: 48-bit Unix millisecond timestamp followed by 80 random bits.
Total length: prefix + 1 + 26 chars. For a typical 3–5 char prefix, that's 30–32 characters.
Why default
Sortable by creation time
The 48-bit timestamp prefix means IDs generated in order are lexically ordered:
import { typeid } from 'typeid-js'
const a = typeid('user').toString() // user_01j5xkqyz8x3n4m9pvabcd
await new Promise(r => setTimeout(r, 1))
const b = typeid('user').toString() // user_01j5xkqyz8x3n4n0pcdefgh
a < b // trueORDER BY id works as a cheap "ORDER BY createdAt" with no separate column. Cursor pagination via paginateById exploits this.
Doubleclick-selectable
Modern browsers treat _ as a word-boundary character. Doubleclicking on user_01j5xkqyz8x3n4m9pvabcd in a log line, terminal, or page selects the WHOLE id. UUIDv4's dashes break this — 01234567-89ab-... selects only one of the hyphen-separated chunks.
This is the difference between "I can paste this from a Slack log into a SQL query" and "I have to manually fix the substring on every paste".
URL-friendly
Only [a-z0-9_] — no percent-encoding needed. /posts/post_01j5xkqyz8x3n4m9pvabcd is exactly 31 characters in the URL with no escaping overhead. Compare to UUID's dashes (which RFC-3986 allows but tooling sometimes mangles) or base64-encoded random bytes (which need URL-safe base64 + are rarely the right call anyway).
Branded at the TypeScript level
The framework's schema registry brands each table's ID type. UserId ≠ OrgId ≠ ProjectId at compile time, even though they're all string at runtime:
function getUser(id: UserId): Promise<User> { ... }
const user: User = ...
const org: Org = ...
getUser(user.id) // ✓ typechecks
getUser(org.id) // ✗ Type 'OrgId' not assignable to 'UserId'
getUser('plain-string') // ✗ Type 'string' not assignable to 'UserId'The brand flows through reference() columns — posts.authorId is UserId not string, so FK lookups can't accept the wrong table's ID by accident.
Compact
~30 chars vs UUIDv4's 36 (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx). Multiplies across foreign keys + log lines + URL params. Snowflake (8 bytes) is denser still but loses the type tag.
Prefix resolution
Two paths:
Explicit prefix
id({ prefix: 'foo' }) // → 'foo_01j5xkqyz8x3n4m9pvabcd'Use when the implicit derivation would produce something ugly or non-unique.
Implicit (derived from table name)
table('users', { id: id() }) // → 'user_01j5...'
table('todos', { id: id() }) // → 'todo_01j5...'
table('apps', { id: id() }) // → 'app_01j5...'
table('posts', { id: id() }) // → 'post_01j5...'The derivation runs at schema-registration time via a simple-singularize:
- Strip trailing
sfrom plural nouns. - Special-case nothing else.
For unconventional plurals (feet, children, status, data, series), set an explicit prefix to avoid the resolver guessing wrong:
table('feet', { id: id({ prefix: 'foot' }) })
table('children', { id: id({ prefix: 'child' }) })
table('status', { id: id({ prefix: 'status' }) }) // identical singular/plural
table('data', { id: id({ prefix: 'datum' }) })The schema registry validates prefix at registration — a prefix collision across two tables throws at boot with both call sites pointed at. You can't accidentally use user_ for both users and system_users.
Lifecycle
// Production handler — id auto-injected by the mutation middleware.
const row = await ctx.store.insert('todos', { title: 'hello', done: false })
// row.id === 'todo_01j5xkqyz8x3n4m9pvabcd'
// Test — deterministic id for assertion.
await ctx.store.insert('todos', { id: 'todo_pinned_for_test', title, done: false })
// Signup self-stamping — actor is the row being created.
import { typeid } from 'typeid-js'
const userId = typeid('user').toString() // pre-compute the id
await ctx.store.insert('users', { id: userId, email, createdBy: userId })Storage
TypeID values are strings. On every dialect:
VARCHAR(64)on mysql / mariadb / mssql (length set explicitly because MySQL'sTEXTcan't be a PK).TEXTon postgres / sqlite (typed strings of arbitrary length).
The PK index is a B-tree. Lookup is O(log n) on every dialect; the framework's eager-load join compiler relies on indexed PK lookup for child rows.
Performance
TypeID's body is monotonically increasing in time, so:
- Index hot spots are predictable: new inserts cluster at the high end of the PK B-tree. Postgres / mysql leaf-page splits happen at the right edge — no random insertion penalty.
- Range scans by id approximate range scans by createdAt: useful for cursor pagination, batch deletes by age, and audit queries.
Caveats
- Renaming a table breaks branded type compatibility. If you rename
users→customers, everyUserId-typed function signature now wantsCustomerId. The framework's typecheck surfaces this at the call site, which is the right place — fix the migrations + propagate the rename through code. - Prefix changes are silent. Changing
id({ prefix: 'usr' })→id({ prefix: 'user' })on an existing table makes NEW rows prefixed differently from OLD rows. The framework doesn't migrate existing IDs. Pick the prefix carefully + change it only with a data migration. - The 26-char body is canonical lowercase. Don't uppercase typeids for display — it breaks the spec and Crockford-base32's case-insensitivity gets confusing.
Where it lives
voltro/packages/database/src/idGenerator.ts—generateId(scheme, tableName)wires uptypeid-js;deriveTypeIdPrefix(tableName)does the singularizevoltro/packages/runtime/src/schemaRegistry.ts— branded type registrationvoltro/packages/runtime/src/storeMiddleware.ts— auto-injection on insert