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 string

Why this exists

Voltro has two ways to enforce a closed value set on a text column:

  1. 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).
  2. 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 dbEnum is the same CHECK constraint, but oneOf keeps 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.enumlabel catalog 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

  • Columnstext().oneOf([...]) for the cross-dialect CHECK form
  • Migrations — the planner's classification for enum changes