ULID — bare, no prefix
26-char Crockford-base32 ULID without a prefix tag. For public-facing IDs where the table type should not leak from the ID itself.
ULID drops TypeID's prefix and keeps just the 26-character body. Use when the mere existence of a row in a specific table is sensitive — leaking the table tag in the ID leaks the row's category.
import { id } from '@voltro/database'
export const inviteTokens = table('invite_tokens', {
id: id({ scheme: 'ulid' }), // → '01j5xkqyz8x3n4m9pvabcd' (no prefix)
invitedBy: reference(() => users),
email: text(),
expiresAt: timestamp(),
})Spec
ULID is the Universally Unique Lexicographically Sortable Identifier. The framework implements it via the ulidx library — a Crockford-base32 encoder/decoder with monotonic guarantees.
- Length: 26 characters.
- Alphabet: Crockford-base32 (no
I,L,O,U). - Body: 48-bit Unix millisecond timestamp + 80 random bits — same as TypeID's body component.
- Case: lowercase canonical (
01j5xkqyz8x3n4m9pvabcd). Crockford-base32 is case-insensitive but the framework + ulidx emit lowercase.
The 48-bit timestamp prefix means ULIDs sort by creation time — same property as TypeID, just without the type tag in front.
When to pick ULID over TypeID
Three cases, in order of how often they apply:
1. Existence of the row is sensitive
For invite tokens, share links, magic-link references, password-reset tokens — the URL containing the ID is the secret. If the prefix invite_ is in the URL, an attacker who sees https://app/i/invite_01j5xkqyz8x3n4m9pvabcd knows the row is an invite_tokens entry, not a public document. That information is itself useful for targeted attacks.
// TypeID — leaks "this is an invite" in the URL.
export const inviteTokens = table('invite_tokens', {
id: id({ prefix: 'invite' }),
})
// URL: https://app/accept?token=invite_01j5xkqyz8x3n4m9pvabcd
// └──┘ — leaks the category
// ULID — opaque from the URL alone.
export const inviteTokens = table('invite_tokens', {
id: id({ scheme: 'ulid' }),
})
// URL: https://app/accept?token=01j5xkqyz8x3n4m9pvabcdThe framework's schema-registry brand still flows — InviteTokenId ≠ UserId at compile time even though both are 26-char strings at runtime.
2. Interop with a system that expects bare ULIDs
If you're integrating with software that already standardized on bare ULIDs and you want the framework's IDs to be drop-in compatible, ULID gives you that without a translation step.
3. You really hate the prefix aesthetic
Subjective, but valid. Some teams prefer the cleaner look of 01j5xkqyz8x3n4m9pvabcd over user_01j5xkqyz8x3n4m9pvabcd. Decide at the schema level — picking ULID for the whole table is the right place; trying to strip prefixes in display code is a mess.
What you lose vs TypeID
- Doubleclick selection. ULIDs are pure alphanumeric so this works fine — same as TypeID.
- Type-tag at-a-glance in logs. Greppping for
user_involtro logswon't catch ULID-typed entities. The compile-time brand is still there but you lose the runtime affordance. - Disambiguation in mixed-table dumps. If you
SELECT id FROM users UNION SELECT id FROM orgsand grep the result, ULIDs are visually identical across tables. TypeID makes the source self-evident.
What you DON'T lose
- Sortable by creation time: ULID's timestamp prefix is identical to TypeID's body.
ORDER BY idworks the same. - Branded at the TypeScript level: the framework's schema registry brands
InviteTokenIdregardless of the wire format. - Cursor pagination via
paginateById: works on ULID columns the same as TypeID.
Storage
VARCHAR(64) on mysql / mariadb / mssql, TEXT on postgres / sqlite / turso. Same column type as TypeID — the framework doesn't tune the column shape per scheme. The PK B-tree behaves identically (timestamp-leading lexical order).
Migration between schemes
ULID and TypeID share the same body format. Going from TypeID user_01j5xkqyz8x3n4m9pvabcd to ULID 01j5xkqyz8x3n4m9pvabcd is a string-prefix strip. Going the other way is a string-prefix prepend. The framework doesn't ship a one-shot migrator; if you flip the scheme on an existing table, write a one-off UPDATE.
-- TypeID → ULID
UPDATE invite_tokens SET id = SUBSTRING(id, 8) WHERE id LIKE 'invite_%';
-- ULID → TypeID
UPDATE invite_tokens SET id = 'invite_' || id WHERE id NOT LIKE 'invite_%';Run inside a transaction. Both directions invalidate any URL or external reference that embedded the old form — coordinate with the consumers.
Caveats
- Monotonicity within the same millisecond is not guaranteed. ULID spec says two IDs generated in the same ms can be sorted in any order. For most uses this is fine; for nano-precision audit logs, prefer Snowflake (which uses a sequence counter within each ms).
- No type discrimination in error messages. A handler that logs "couldn't find id
01j5xkqyz8x3n4m9pvabcd" gives the user no hint which table to check. TypeID's prefix is the better choice for any ID a user might encounter in an error message. - Don't try to derive type from the ID at runtime. The framework's brand exists at compile time only; once a string crosses an API boundary as
unknown, you can't tell what table it belongs to without looking it up. If runtime discrimination matters, use TypeID.
Where it lives
voltro/packages/database/src/idGenerator.ts—generateId(scheme, tableName)wires upulidxfor theulidschemevoltro/packages/runtime/src/schemaRegistry.ts— ULID-scheme registration (no prefix to resolve)