Enums (dbEnum)
Postgres-native ENUM types via dbEnum() — cheap ADD VALUE migrations, full type-narrowing. Falls back to CHECK constraints on other dialects.
dbEnum('name', [values]) declares a postgres-native ENUM type
that's reusable across columns and tables. The literal value union
narrows the row type at compile time; cheap ALTER TYPE ... ADD VALUE migrations replace CHECK-rewrite pain.
Quick start
import { dbEnum, id, table } from '@voltro/database'
export const orderStatus = dbEnum('order_status', ['pending', 'paid', 'shipped'] as const)
export const orders = table('orders', {
id: id(),
status: orderStatus.column().default('pending'),
})
// row.status is typed `'pending' | 'paid' | 'shipped'`, not plain stringWhy this exists
Voltro has two ways to enforce a closed value set on a text column:
text().oneOf(['a', 'b'])— emits a CHECK constraint. Cross-dialect, works on every backend, but ADD VALUE is a CHECK rewrite (expensive on a 10M-row table).dbEnum('name', [...])— postgres-native ENUM type.ALTER TYPE ... ADD VALUE 'new_value'is O(1).
Use dbEnum when:
- You're on postgres AND
- The value set is reasonably stable but might grow occasionally AND
- You want the value set self-documenting in the schema
Use oneOf when:
- You're on multiple dialects (the framework's cross-dialect fallback
for
dbEnumis the same CHECK constraint, butoneOfkeeps the source code aligned with reality). - The value set is highly stable (the cheap ADD VALUE doesn't help).
Reuse across tables
export const status = dbEnum('item_status', ['draft', 'published', 'archived'] as const)
export const articles = table('articles', { id: id(), status: status.column() })
export const videos = table('videos', { id: id(), state: status.column() })The CREATE TYPE DDL emits exactly once — the framework dedups by
enum name across all schema tables. Defining the same name with
different value sets throws at DDL time.
Defaults
status: orderStatus.column().default('pending')Default must be one of the declared values — TypeScript narrows the
.default(...) parameter to the literal union.
ADD VALUE migrations
// Day 1
export const orderStatus = dbEnum('order_status', ['pending', 'paid', 'shipped'] as const)
// Day 30 — add a value
export const orderStatus = dbEnum('order_status', ['pending', 'paid', 'shipped', 'returned'] as const)voltro db plan detects the new value and emits an add-enum-value
op:
ALTER TYPE "order_status" ADD VALUE 'returned';On postgres this is O(1) — no table rewrite. On mysql/mariadb/mssql/sqlite/turso (CHECK fallback) the planner emits a CHECK-recreate, which is a brief lock but doesn't rewrite the table.
Renaming values
A value rename looks identical to a drop+add in a naive value-set
diff — and a drop+add would orphan every row already storing the old
label. So a rename needs explicit INTENT, exactly like a column rename
needs .renamedFrom('oldName'). Declare it with declareEnumRename,
placed next to the dbEnum(...) whose value array now carries the new
label:
import { dbEnum, declareEnumRename } from '@voltro/database'
// The enum now spells the NEW value.
export const orderStatus = dbEnum('order_status', ['awaiting', 'paid', 'shipped'] as const)
// Record that 'pending' was renamed to 'awaiting'.
declareEnumRename('order_status', { from: 'pending', to: 'awaiting' })The migrator then emits a guarded, data-preserving rename — it fires only when the OLD label still exists and the NEW one doesn't, so re-runs are no-ops:
DO $
DECLARE type_oid oid;
BEGIN
SELECT oid INTO type_oid FROM pg_type WHERE typname = 'order_status';
IF type_oid IS NOT NULL
AND EXISTS (SELECT 1 FROM pg_enum WHERE enumtypid = type_oid AND enumlabel = 'pending')
AND NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumtypid = type_oid AND enumlabel = 'awaiting') THEN
IF current_setting('server_version_num')::int >= 140000 THEN
EXECUTE format('ALTER TYPE %I RENAME VALUE %L TO %L', 'order_status', 'pending', 'awaiting');
ELSE
UPDATE pg_enum SET enumlabel = 'awaiting' WHERE enumtypid = type_oid AND enumlabel = 'pending';
END IF;
END IF;
END $;- Postgres 14+ uses the native
ALTER TYPE … RENAME VALUE 'old' TO 'new'— cheap, in-place, no table rewrite. - Postgres 13 and below falls back to a data-preserving
pg_enum.enumlabelcatalog update — every row keeps its value, the label is simply re-spelled. - Non-postgres dialects store enums as text + a CHECK constraint, so the new value list lands in the recreated CHECK on its own — no dedicated rename DDL is needed.
Keep the declareEnumRename(...) declaration until the rename has
applied in every environment you care about, then remove it (the new
value is by then the only label the enum knows).
Cross-dialect
| Dialect | Emits |
|---|---|
| postgres | CREATE TYPE "name" AS ENUM (...) + <col> name (native) |
| mysql / mariadb | <col> ENUM('a','b','c') (native enum column type) |
| mssql / sqlite | <col> NVARCHAR(255) / TEXT + CHECK (col IN ('a','b','c')) (fallback) |
The postgres path is the cheap one; the rest treat enums as text + CHECK behind the scenes.
When NOT to use
- You're on mysql or sqlite primarily — the postgres-only benefit
doesn't apply. Use
text().oneOf([...])for consistency. - You need value-level metadata (descriptions, sort orders) — enums are just strings. Build a lookup table.
- The value set changes weekly — every change is still a schema migration. For high-churn lists, store the values in a table.
See also
- Columns —
text().oneOf([...])for the cross-dialect CHECK form - Migrations — the planner's classification for enum changes