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 by typeid-js). Up to 63 chars (practical limit: keep it short).
  • Separator: single underscore _.
  • Body: 26 characters in Crockford-base32 (no i, l, o, u to 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   // true

ORDER 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. UserIdOrgIdProjectId 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 s from 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's TEXT can't be a PK).
  • TEXT on 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 userscustomers, every UserId-typed function signature now wants CustomerId. 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.tsgenerateId(scheme, tableName) wires up typeid-js; deriveTypeIdPrefix(tableName) does the singularize
  • voltro/packages/runtime/src/schemaRegistry.ts — branded type registration
  • voltro/packages/runtime/src/storeMiddleware.ts — auto-injection on insert