Enums (dbEnum)
Postgres-native ENUM types via dbEnum() — cheap ADD VALUE migrations, full type-narrowing. Native on postgres and the mysql family, CHECK-backed on mssql and sqlite.
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 want ONE mechanism everywhere.
dbEnumis portable — it is planned and applied on every dialect — but what it emits differs by dialect (see Cross-dialect below), sooneOfis the simpler thing to reason about if you never need the cheap ADD VALUE. - 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. The other dialects are
planned too, by two different mechanisms: the mysql family redeclares
the column (MODIFY COLUMN … ENUM(…) with the full set), mssql and
sqlite recreate the CHECK. See Cross-dialect below.
The value set is part of the schema fingerprint, so a production
voltro serve refuses to boot until the change has been applied,
exactly as it does for a new column. That is the point: the label is a
promise your code makes to the database, and a boot that ignored it
would fail at the first write instead — invalid input value for enum.
Removing a value
A removed value is deliberately not an operation. Postgres has no
DROP VALUE; removing a label means recreating the type and rewriting
every column that uses it, which the planner will not do behind your
back. Nothing breaks while the label lingers — your code never writes
it, and old rows still read.
What the planner will not do silently is pretend the two sides match.
voltro db plan reports the table under its fingerprint divergence:
0 operations
declared and live differ without a planned change: orders
That line is the whole feature. An empty plan under two different fingerprints is the one state no command can resolve, so the difference is named rather than hidden. If you want the label really gone, that is a file-based migration recreating the type — and worth asking first whether an unused label is costing you anything.
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. - Every other dialect carries the label set inside the column
itself — a native
ENUM(…)on the mysql family, a CHECK on mssql and sqlite — so the new list lands when the column or constraint is rewritten. No dedicated rename DDL is needed there.
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) |
Three mechanisms, not two — the mysql family has a native enum column type of its own and does NOT go through text + CHECK. That matters because adding a label takes a different statement on each:
| Dialect | Adding a label |
|---|---|
| postgres | ALTER TYPE … ADD VALUE — O(1), no table rewrite |
| mysql / mariadb | ALTER TABLE … MODIFY COLUMN … ENUM(…) with the full set |
| mssql / sqlite | the CHECK is recreated (a table rebuild on sqlite) |
All three are planned and applied for you; the row exists so you can predict the cost on your own dialect before a deploy window.
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